diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 0c01ef2..53075c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -71,9 +71,11 @@ interface CalendarDataSource { /** * Every master/one-off event of the writable local calendars, mapped for a * whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception - * rows are excluded (see [EventExportProjection]). + * rows are excluded (see [EventExportProjection]). When [calendarIds] is + * given, only those calendars are exported (still intersected with the + * eligible set); `null` exports every eligible calendar. */ - fun exportableEvents(): List + fun exportableEvents(calendarIds: Set? = null): List /** * The non-empty `Events.UID_2445` values present in [calendarId] — used to @@ -611,12 +613,19 @@ class AndroidCalendarDataSource @Inject constructor( ?: emptyList() } - override fun exportableEvents(): List { + override fun exportableEvents(calendarIds: Set?): List { // Only the local calendars the app owns and can write — synced calendars - // already have a backup (their server). Map id → display name for the + // already have a backup (their server). Exclude the managed special-dates + // mirror calendars: their events are derived from contacts, not authored + // here, and re-materialise from the contact sync — backing them up would + // just duplicate them on restore. A non-null [calendarIds] narrows the + // export to the user's chosen subset. Map id → display name for the // X-CALENDULA-CALENDAR tag a restore uses to fan back out. val names = calendars() - .filter { it.isLocal && it.canModifyContents } + .filter { + it.isLocal && it.canModifyContents && !it.isManaged && + (calendarIds == null || it.id in calendarIds) + } .associate { it.id to it.displayName } if (names.isEmpty()) return emptyList() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index 27c20ce..422e361 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -41,8 +41,9 @@ interface CalendarRepository { /** * Every event of the writable local calendars, ready to serialise into a * whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]). + * [calendarIds] narrows the export to a chosen subset; `null` exports all. */ - suspend fun exportEvents(): List + suspend fun exportEvents(calendarIds: Set? = null): List /** * Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index bde325a..9ca1d7a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -117,7 +117,8 @@ class CalendarRepositoryImpl @Inject constructor( override suspend fun deleteCalendar(id: Long) = withContext(io) { dataSource.deleteCalendar(id) } - override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() } + override suspend fun exportEvents(calendarIds: Set?) = + withContext(io) { dataSource.exportableEvents(calendarIds) } override suspend fun importEvents( targetCalendarId: Long, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 3e66a43..3e270a6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -172,9 +172,15 @@ fun CalendarHost( // picker (many). A plain conditional overlay (no slide) — it's transient. var importUri by remember { mutableStateOf(null) } var importForm by remember { mutableStateOf(null) } + // A restore (in-app "Restore from .ics" button) always runs the full import + // flow — picker + summary — even for a single-event file, because the intent + // is "restore a backup", not "add this one event". An externally opened .ics + // keeps routing a single event straight into the prefilled create form. + var importForceMany by remember { mutableStateOf(false) } LaunchedEffect(requestedImportUri) { if (requestedImportUri != null) { importUri = requestedImportUri + importForceMany = false onImportConsumed() } } @@ -414,7 +420,10 @@ fun CalendarHost( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), ) { - CalendarsScreen(onBack = { showCalendars = false }) + CalendarsScreen( + onBack = { showCalendars = false }, + onImport = { importUri = it; importForceMany = true }, + ) } // Import flow for an opened/received .ics file. A single event routes @@ -422,6 +431,7 @@ fun CalendarHost( importUri?.let { uri -> ImportScreen( uri = uri, + forceMany = importForceMany, onClose = { importUri = null }, onOpenSingle = { form -> importUri = null diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 9d087c6..846a18b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -8,7 +8,6 @@ import android.text.format.DateUtils import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -37,6 +35,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileUpload import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PhoneAndroid @@ -45,6 +44,7 @@ import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -66,6 +66,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -73,10 +74,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringArrayResource @@ -85,7 +83,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.hilt.navigation.compose.hiltViewModel @@ -96,6 +93,11 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker +import de.jeanlucmakiola.calendula.ui.common.positionOf +import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar +import de.jeanlucmakiola.calendula.ui.common.SourceLogo +import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage import de.jeanlucmakiola.calendula.ui.common.DialogAmountField import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit @@ -112,6 +114,16 @@ import java.time.LocalDate /** Sentinel [editorId] meaning "the editor is composing a new calendar". */ private const val NEW_CALENDAR_ID = Long.MIN_VALUE +// SAF mime filter for the restore picker. `.ics` files reach us under several +// mimes depending on the source app (our own export uses text/calendar; others +// hand them out as octet-stream or text/plain), so accept the common set rather +// than hide valid backups behind an over-tight filter. +private val RESTORE_MIME_TYPES = arrayOf( + "text/calendar", + "application/octet-stream", + "text/plain", +) + /** * Calendar manager (reached from Settings). Lists the app's own device-only * calendars with create / rename / recolor / delete (via a full-screen editor), @@ -122,6 +134,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE @Composable fun CalendarsScreen( onBack: () -> Unit, + onImport: (android.net.Uri) -> Unit, viewModel: CalendarsViewModel = hiltViewModel(), ) { val calendars by viewModel.calendars.collectAsStateWithLifecycle() @@ -168,6 +181,7 @@ fun CalendarsScreen( onConsumeError = viewModel::consumeError, backupResult = backupResult, onExportBackup = viewModel::exportBackup, + onImport = onImport, onConsumeBackupResult = viewModel::consumeBackupResult, autoBackup = autoBackup, onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled, @@ -190,7 +204,8 @@ private fun CalendarsList( error: Boolean, onConsumeError: () -> Unit, backupResult: BackupResult?, - onExportBackup: (android.net.Uri) -> Unit, + onExportBackup: (android.net.Uri, Set?) -> Unit, + onImport: (android.net.Uri) -> Unit, onConsumeBackupResult: () -> Unit, autoBackup: AutoBackupUiState, onSetAutoBackupEnabled: (Boolean) -> Unit, @@ -218,10 +233,19 @@ private fun CalendarsList( } // SAF "create document" target for the backup file. The picked Uri is handed - // to the VM to stream the .ics into. + // to the VM to stream the .ics into. This launcher exports everything + // eligible (null); the per-calendar selector owns its own launcher. val createBackup = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("text/calendar"), - ) { uri -> uri?.let(onExportBackup) } + ) { uri -> uri?.let { onExportBackup(it, null) } } + var showExportPicker by rememberSaveable { mutableStateOf(false) } + + // SAF "open document" picker for restoring events from a .ics file. The + // picked Uri is handed up to the host, which runs it through the same import + // flow as an externally opened .ics (parse, dedup by UID, target picker). + val openBackup = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> uri?.let(onImport) } // SAF folder picker for the automatic-backup destination; the VM persists the // write grant so background runs can keep writing to it. @@ -301,8 +325,13 @@ private fun CalendarsList( } // Backup — local calendars have no sync, so a .ics export is their only - // safety net. Offered only when there is something to back up. - if (local.isNotEmpty()) { + // safety net. Offered only when there is something exportable: the user's + // own local calendars (managed special-dates mirrors don't count). + val exportable = local.filter { it.canModifyContents && !it.isManaged } + // Restore/import can target any writable, non-managed calendar (local or + // synced), so its availability is broader than export's. + val canImport = (local + synced).any { it.canModifyContents && !it.isManaged } + if (exportable.isNotEmpty()) { Spacer(Modifier.height(16.dp)) SectionHeader(stringResource(R.string.calendars_backup_header)) HintText(stringResource(R.string.calendars_backup_hint)) @@ -313,7 +342,22 @@ private fun CalendarsList( position = Position.Top, leading = { LeadingAvatar(Icons.Default.FileDownload) }, onClick = { - runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } + // With more than one exportable calendar, let the user choose + // which to include; a single one exports straight away. + if (exportable.size == 1) { + runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } + } else { + showExportPicker = true + } + }, + ) + GroupedRow( + title = stringResource(R.string.calendars_restore_action), + summary = stringResource(R.string.calendars_restore_hint), + position = Position.Middle, + leading = { LeadingAvatar(Icons.Default.FileUpload) }, + onClick = { + runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, ) GroupedRow( @@ -342,6 +386,19 @@ private fun CalendarsList( ) HintText(backupStatusText(autoBackup.status)) } + } else if (canImport) { + // Nothing to back up (no writable local calendar), but events can + // still be restored into a writable calendar — offer restore on its + // own so it isn't hidden behind export eligibility. + Spacer(Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.calendars_restore_header)) + HintText(stringResource(R.string.calendars_restore_hint)) + GroupedRow( + title = stringResource(R.string.calendars_restore_action), + position = Position.Alone, + leading = { LeadingAvatar(Icons.Default.FileUpload) }, + onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, + ) } Spacer(Modifier.height(16.dp)) @@ -408,6 +465,86 @@ private fun CalendarsList( onDismiss = { showInterval = false }, ) } + + if (showExportPicker) { + ExportCalendarPicker( + calendars = local.filter { it.canModifyContents && !it.isManaged }, + onExport = onExportBackup, + onDismiss = { showExportPicker = false }, + ) + } +} + +/** + * Choose which local calendars to include in a one-time `.ics` export. Defaults + * to all selected; the Export action opens the SAF save dialog and hands back + * the picked file with the chosen calendar ids. + */ +@Composable +private fun ExportCalendarPicker( + calendars: List, + onExport: (android.net.Uri, Set?) -> Unit, + onDismiss: () -> Unit, +) { + // Seed once with everything selected and hold it across recomposition and + // rotation. NOT keyed on [calendars]: the list is observer-driven, so keying + // it would silently reset the user's de-selections whenever the provider + // re-emits (a background sync, a recolor). Ids that later vanish are harmless + // — the data layer intersects the chosen set with the eligible calendars. + var selected by rememberSaveable( + stateSaver = listSaver( + save = { it.toList() }, + restore = { it.toSet() }, + ), + ) { + mutableStateOf(calendars.map { it.id }.toSet()) + } + val createBackup = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("text/calendar"), + ) { uri -> + if (uri != null) { + onExport(uri, selected) + onDismiss() + } + } + + FullScreenPicker( + title = stringResource(R.string.calendars_export_title), + onDismiss = onDismiss, + ) { + HintText(stringResource(R.string.calendars_export_hint)) + calendars.forEachIndexed { index, calendar -> + val isSelected = calendar.id in selected + GroupedRow( + title = calendar.displayName, + summary = calendar.description, + position = positionOf(index, calendars.size), + leading = { CalendarColorChip(calendar.color) }, + trailing = { + Checkbox( + checked = isSelected, + onCheckedChange = { checked -> + selected = if (checked) selected + calendar.id else selected - calendar.id + }, + ) + }, + onClick = { + selected = if (isSelected) selected - calendar.id else selected + calendar.id + }, + ) + } + Button( + onClick = { + runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } + }, + enabled = selected.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + ) { + Text(stringResource(R.string.calendars_export_action)) + } + } } @OptIn(ExperimentalMaterial3Api::class) @@ -741,66 +878,6 @@ private fun CalendarGroupMenu( } } -/** - * The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a - * round 40dp chip, so each synced account is recognisable at a glance. We load - * whatever app owns the account from [PackageManager] rather than bundling brand - * logos — always accurate, nothing to license. Falls back to a neutral cloud - * chip when no installed app resolves for the account. - */ -@Composable -private fun SourceLogo(accountType: String) { - val context = LocalContext.current - val logo = remember(accountType) { sourceAppLogo(context, accountType) } - if (logo != null) { - Image( - bitmap = logo, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - ) - } else { - LeadingAvatar(Icons.Default.Cloud) - } -} - -/** The launcher icon of the app backing [accountType], preferring the human-facing app. */ -private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? { - val pm = context.packageManager - val candidates = buildList { - curatedSourcePackage(accountType)?.let { add(it) } - AccountManager.get(context).authenticatorTypes - .firstOrNull { it.type.equals(accountType, ignoreCase = true) } - ?.packageName - ?.let { add(it) } - } - for (pkg in candidates) { - val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull() - if (bitmap != null) return bitmap.asImageBitmap() - } - return null -} - -/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */ -@Composable -private fun LeadingAvatar(icon: ImageVector) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - contentAlignment = Alignment.Center, - ) { - Icon( - icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - } -} @Composable private fun SectionHeader(text: String) { @@ -935,9 +1012,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent { return Intent(Settings.ACTION_SYNC_SETTINGS) } -/** Preferred app for account types whose authenticator isn't the app to open. */ -private fun curatedSourcePackage(accountType: String): String? = when { - accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar" - else -> null -} - diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index bfadaf6..8439616 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -107,11 +107,11 @@ class CalendarsViewModel @Inject constructor( * document [uri] as one `VCALENDAR`. Result (event count, or failure) lands * in [backupResult] for a one-shot message. */ - fun exportBackup(uri: Uri) { + fun exportBackup(uri: Uri, calendarIds: Set? = null) { viewModelScope.launch { _backupResult.value = try { val count = withContext(io) { - val events = repository.exportEvents() + val events = repository.exportEvents(calendarIds) icsExporter.writeDocument( uri = uri, content = IcsWriter().writeCalendar(events, Clock.System.now()), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt new file mode 100644 index 0000000..758bc0c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -0,0 +1,180 @@ +package de.jeanlucmakiola.calendula.ui.common + +import android.accounts.AccountManager +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.CalendarSource + +/** + * The app's single "which calendar" selection list, shared by the event editor + * and the .ics import screen. Renders the same grouped-card system as the + * calendar-manager screen: a category header per source — the device chip for + * the app's own calendars, the owning app's launcher icon for each synced + * account — with the calendars beneath it as a connected card, a colour chip on + * each and a check on the selected one. Emits into the caller's [ColumnScope] + * (a scrolling column), so the caller owns the surrounding chrome. + */ +@Composable +fun ColumnScope.CalendarPickerGroups( + calendars: List, + selectedId: Long?, + onSelect: (Long) -> Unit, +) { + val local = remember(calendars) { calendars.filter { it.isLocal } } + val syncedGroups = remember(calendars) { + calendars.filterNot { it.isLocal } + .groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } } + .toList() + } + + if (local.isNotEmpty()) { + CalendarPickerGroup( + title = stringResource(R.string.calendars_local_header), + leading = { LeadingAvatar(Icons.Default.PhoneAndroid) }, + calendars = local, + selectedId = selectedId, + onSelect = onSelect, + ) + } + syncedGroups.forEachIndexed { index, (account, cals) -> + if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp)) + CalendarPickerGroup( + title = account, + leading = { SourceLogo(cals.first().accountType) }, + calendars = cals, + selectedId = selectedId, + onSelect = onSelect, + ) + } +} + +/** One account's category header (avatar + name) atop its selectable calendars. */ +@Composable +private fun CalendarPickerGroup( + title: String, + leading: @Composable () -> Unit, + calendars: List, + selectedId: Long?, + onSelect: (Long) -> Unit, +) { + GroupedRow( + title = title, + position = Position.Top, + leading = leading, + ) + calendars.forEachIndexed { index, calendar -> + val isSelected = calendar.id == selectedId + GroupedRow( + title = calendar.displayName, + position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle, + selected = isSelected, + leading = { CalendarColorChip(calendar.color) }, + trailing = if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + onClick = { onSelect(calendar.id) }, + ) + } +} + +/** + * The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a + * round 40dp chip, so each synced account is recognisable at a glance. We load + * whatever app owns the account from [android.content.pm.PackageManager] rather + * than bundling brand logos — always accurate, nothing to license. Falls back to + * a neutral cloud chip when no installed app resolves for the account. + */ +@Composable +fun SourceLogo(accountType: String) { + val context = LocalContext.current + val logo = remember(accountType) { sourceAppLogo(context, accountType) } + if (logo != null) { + Image( + bitmap = logo, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + ) + } else { + LeadingAvatar(Icons.Default.Cloud) + } +} + +/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */ +@Composable +fun LeadingAvatar(icon: ImageVector) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } +} + +/** The launcher icon of the app backing [accountType], preferring the human-facing app. */ +private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? { + val pm = context.packageManager + val candidates = buildList { + curatedSourcePackage(accountType)?.let { add(it) } + AccountManager.get(context).authenticatorTypes + .firstOrNull { it.type.equals(accountType, ignoreCase = true) } + ?.packageName + ?.let { add(it) } + } + for (pkg in candidates) { + val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull() + if (bitmap != null) return bitmap.asImageBitmap() + } + return null +} + +/** Preferred app for account types whose authenticator isn't the app to open. */ +internal fun curatedSourcePackage(accountType: String): String? = when { + accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar" + else -> null +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index ac1c6f9..94ae3e8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Contacts import androidx.compose.material.icons.filled.EventAvailable @@ -121,7 +120,7 @@ import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette -import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.DialogAmountField @@ -1975,50 +1974,14 @@ private fun CalendarPicker( onSelect: (Long) -> Unit, onDismiss: () -> Unit, ) { - // Group by owning account (name, else type, else the calendar's own name), - // preserving the provider's order within and across groups — the same - // grouping [groupByAccount] applies for the drawer filter. - val groups = remember(calendars) { - calendars - .groupBy { - it.accountName.takeIf(String::isNotBlank) - ?: it.accountType.takeIf(String::isNotBlank) - ?: it.displayName - } - .toList() - } FullScreenPicker( title = stringResource(R.string.event_detail_calendar), onDismiss = onDismiss, ) { - groups.forEach { (account, cals) -> - Text( - text = account, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), - ) - cals.forEachIndexed { index, calendar -> - val isSelected = calendar.id == selectedId - GroupedRow( - title = calendar.displayName, - position = positionOf(index, cals.size), - selected = isSelected, - leading = { CalendarColorChip(calendar.color) }, - trailing = if (isSelected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - } else { - null - }, - onClick = { onSelect(calendar.id) }, - ) - } - } + CalendarPickerGroups( + calendars = calendars, + selectedId = selectedId, + onSelect = onSelect, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index 881b836..de3e9c7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -1,15 +1,27 @@ package de.jeanlucmakiola.calendula.ui.imports import android.net.Uri +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -18,6 +30,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -30,6 +43,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -38,14 +57,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning +import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.predictiveBack -import de.jeanlucmakiola.calendula.ui.common.OptionCard /** * Handles an opened/received `.ics` file. A single event is handed straight to * the prefilled create form via [onOpenSingle]; several events show a target- * calendar picker and import in bulk (dedup by UID), then a result summary. - * Empty/failed files show a short message and close. + * Empty/failed files show a short message and close. [forceMany] keeps a + * single-event file on the bulk path — used by the in-app restore, whose intent + * is "restore a backup" rather than "add this one event". */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -53,9 +74,16 @@ fun ImportScreen( uri: Uri, onClose: () -> Unit, onOpenSingle: (EventForm) -> Unit, - viewModel: ImportViewModel = hiltViewModel(), + forceMany: Boolean = false, + // Key the VM by the file uri. This screen has no nav backstack, so an + // unkeyed hiltViewModel() resolves to the Activity's store and is retained + // across imports — its one-shot `load` guard would then show the *previous* + // file's parsed state on the next import (a second restore, export→restore, + // etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading + // state), while the same uri (rotation) reuses it and holds the result. + viewModel: ImportViewModel = hiltViewModel(key = uri.toString()), ) { - LaunchedEffect(uri) { viewModel.load(uri) } + LaunchedEffect(uri) { viewModel.load(uri, forceMany) } val state by viewModel.state.collectAsStateWithLifecycle() // A single event isn't shown here — it opens the create form for review. @@ -63,18 +91,55 @@ fun ImportScreen( (state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() } } + // Hoisted target calendar so the always-visible top-bar Import action can + // read it without the user scrolling to a bottom button. Defaults to the + // first *local* calendar — the first row the picker shows ("Your calendars" + // group leads) — so the pre-selection lines up with the top of the list; + // falls back to the first calendar if there are no local ones. Re-defaults + // when the "many" list first arrives (keyed on it), then holds the pick. + val many = state as? ImportUiState.Many + val defaultTarget = many?.calendars?.let { cals -> + (cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id + } + var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) } + Scaffold( modifier = Modifier .predictiveBack(onBack = onClose) .fillMaxSize(), topBar = { TopAppBar( - title = { Text(stringResource(R.string.import_title)) }, + title = { + Text( + if (many != null) { + pluralStringResource( + R.plurals.import_title_count, + many.events.size, + many.events.size, + ) + } else { + stringResource(R.string.import_title) + }, + ) + }, navigationIcon = { IconButton(onClick = onClose) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close)) } }, + actions = { + // Only meaningful in the multi-event picker with a writable + // target; every other state has nothing to confirm here. + if (many != null && many.calendars.isNotEmpty()) { + Button( + onClick = { selected?.let(viewModel::import) }, + enabled = selected != null, + modifier = Modifier.padding(end = 12.dp), + ) { + Text(stringResource(R.string.import_button)) + } + } + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface, ), @@ -90,7 +155,7 @@ fun ImportScreen( ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose) ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose) - is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import) + is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it }) is ImportUiState.Done -> DoneContent(s, onClose) } } @@ -98,84 +163,160 @@ fun ImportScreen( } @Composable -private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) { +private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) { // No writable calendar to import into — tell the user honestly. if (state.calendars.isEmpty()) { CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null) return } - var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) } Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .padding(top = 8.dp, bottom = 24.dp), ) { - Text( - pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(vertical = 8.dp), + CalendarPickerGroups( + calendars = state.calendars, + selectedId = selected, + onSelect = onSelect, ) - Text( - stringResource(R.string.import_target_header), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - ) - state.calendars.forEach { calendar -> - OptionCard( - label = calendar.displayName, - onClick = { selected = calendar.id }, - selected = calendar.id == selected, - icon = null, - ) - } - state.warnings.forEach { WarningText(it) } - Button( - onClick = { onImport(selected) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) { - Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size)) + if (state.warnings.isNotEmpty()) { + Column( + Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + state.warnings.forEach { WarningText(it) } + } } } } @Composable private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) { + // A little expressive pop on the success badge — springs in on first show. + val badgeScale = remember { Animatable(0.7f) } + LaunchedEffect(Unit) { + badgeScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ) + } + Column( Modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + Spacer(Modifier.weight(1f)) + Box( + Modifier + .size(112.dp) + .graphicsLayer { + scaleX = badgeScale.value + scaleY = badgeScale.value + } + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) + } + Spacer(Modifier.height(24.dp)) Text( stringResource(R.string.import_done_title), style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(top = 24.dp), - ) - Text( - pluralStringResource( - R.plurals.import_done_imported, - state.summary.imported, - state.summary.imported, - ), - style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, ) if (state.summary.skippedDuplicate > 0) { + Spacer(Modifier.height(8.dp)) Text( - pluralStringResource( - R.plurals.import_done_skipped, - state.summary.skippedDuplicate, - state.summary.skippedDuplicate, - ), + stringResource(R.string.import_done_dedup_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) } - Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) { + Spacer(Modifier.height(24.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ImportStatCard( + count = state.summary.imported, + label = stringResource(R.string.import_done_added_label), + contentDescription = pluralStringResource( + R.plurals.import_done_imported, + state.summary.imported, + state.summary.imported, + ), + container = MaterialTheme.colorScheme.secondaryContainer, + onContainer = MaterialTheme.colorScheme.onSecondaryContainer, + ) + if (state.summary.skippedDuplicate > 0) { + ImportStatCard( + count = state.summary.skippedDuplicate, + label = stringResource(R.string.import_done_skipped_label), + contentDescription = pluralStringResource( + R.plurals.import_done_skipped, + state.summary.skippedDuplicate, + state.summary.skippedDuplicate, + ), + container = MaterialTheme.colorScheme.surfaceContainerHighest, + onContainer = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.weight(1f)) + Button( + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.import_close)) } } } +/** A big-number tonal tile summarising one import outcome (added / skipped). */ +@Composable +private fun RowScope.ImportStatCard( + count: Int, + label: String, + contentDescription: String, + container: Color, + onContainer: Color, +) { + Surface( + modifier = Modifier + .weight(1f) + .clearAndSetSemantics { this.contentDescription = contentDescription }, + shape = RoundedCornerShape(24.dp), + color = container, + ) { + Column( + Modifier.padding(vertical = 20.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + count.toString(), + style = MaterialTheme.typography.displaySmall, + color = onContainer, + ) + Text( + label, + style = MaterialTheme.typography.labelLarge, + color = onContainer.copy(alpha = 0.85f), + ) + } + } +} + @Composable private fun WarningText(warning: IcsParseWarning) { val text = when (warning) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt index 445a25f..8ba9084 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt @@ -66,8 +66,13 @@ class ImportViewModel @Inject constructor( val state: StateFlow = _state.asStateFlow() private var started = false - /** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */ - fun load(uri: Uri) { + /** + * Read + parse [uri] once; subsequent calls (recomposition) are ignored. + * When [forceMany] is set (an in-app restore), a single-event file still goes + * through the bulk picker + summary rather than the prefilled create form — + * a restore is "bring back a backup", not "add this one event". + */ + fun load(uri: Uri, forceMany: Boolean = false) { if (started) return started = true viewModelScope.launch { @@ -77,19 +82,21 @@ class ImportViewModel @Inject constructor( _state.value = when { parsed == null -> ImportUiState.Failed parsed.events.isEmpty() -> ImportUiState.Empty - parsed.events.size == 1 -> ImportUiState.Single( + parsed.events.size == 1 && !forceMany -> ImportUiState.Single( form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()), warnings = parsed.warnings, ) else -> { // A disabled calendar is removed from the app, so it can't be // an import target — exclude it alongside the read-only ones. + // Managed special-dates calendars are contact-derived and + // editor-locked, so they're not a valid destination either. val disabled = prefs.disabledCalendarIds.first() ImportUiState.Many( events = parsed.events, warnings = parsed.warnings, calendars = repository.calendars().first() - .filter { it.canModifyContents && it.id !in disabled }, + .filter { it.canModifyContents && !it.isManaged && it.id !in disabled }, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c9e8df..19639cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -428,6 +428,12 @@ Backup Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy. Export as .ics file + Export calendars + Choose which calendars to include in the .ics file. + Export + Restore + Restore from .ics file + Import events from a backup or another calendar app. Automatic backup Periodically export your local calendars to a folder as an .ics file. Backup folder @@ -460,11 +466,19 @@ Couldn\'t read this file. No writable calendar to import into. Create a local calendar first. Import complete + Events already in the calendar were skipped. + Added + Duplicates Close Some changed occurrences of recurring events were skipped. An event without a start time was skipped. Guest lists weren\'t imported. An unknown time zone fell back to your device\'s. + Import + + Importing %d event + Importing %d events + %d event in this file. %d events in this file. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 1fb9abd..ed8e728 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -536,6 +536,30 @@ class CalendarRepositoryImplTest { assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L) } + @Test + fun `exportEvents forwards the chosen calendar-id subset to the data source`( + @TempDir tempDir: Path, + ) = runTest { + val fake = FakeCalendarDataSource() + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined) + + repo.exportEvents(calendarIds = setOf(1L, 4L)) + + assertThat(fake.lastExportableEventsCalendarIds).containsExactly(1L, 4L) + } + + @Test + fun `exportEvents defaults to null so every eligible calendar is exported`( + @TempDir tempDir: Path, + ) = runTest { + val fake = FakeCalendarDataSource() + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined) + + repo.exportEvents() + + assertThat(fake.lastExportableEventsCalendarIds).isNull() + } + private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent( uid = uid, summary = "E", diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index f5a023a..d62ddae 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -23,6 +23,8 @@ internal class FakeCalendarDataSource : CalendarDataSource { var eventDetailResult: (Long) -> EventDetail? = { null } var eventColorPaletteResult: (Long) -> List = { emptyList() } var exportableEventsResult: List = emptyList() + /** The [calendarIds] the last [exportableEvents] call received (null = all). */ + var lastExportableEventsCalendarIds: Set? = null /** UIDs the target calendar already holds, for import dedup. */ var existingUidsResult: Set = emptySet() /** Set to make the next write call throw. */ @@ -59,7 +61,10 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId) override fun eventColorPalette(calendarId: Long): List = eventColorPaletteResult(calendarId) - override fun exportableEvents(): List = exportableEventsResult + override fun exportableEvents(calendarIds: Set?): List { + lastExportableEventsCalendarIds = calendarIds + return exportableEventsResult + } override fun existingUids(calendarId: Long): Set = existingUidsResult