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 e251f38..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,15 +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). 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. Map id → display name for the + // 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 && !it.isManaged } + .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/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 06b214e..7a89d24 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 @@ -46,6 +46,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 @@ -97,6 +98,8 @@ 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 @@ -206,7 +209,7 @@ 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, @@ -235,10 +238,12 @@ 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 remember { 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 @@ -325,8 +330,10 @@ 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 } + if (exportable.isNotEmpty()) { Spacer(Modifier.height(16.dp)) SectionHeader(stringResource(R.string.calendars_backup_header)) HintText(stringResource(R.string.calendars_backup_hint)) @@ -337,7 +344,13 @@ 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( @@ -441,6 +454,76 @@ 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, +) { + var selected by remember(calendars) { + 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) 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5ff303..9dcce06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -428,6 +428,9 @@ 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 from .ics file Import events from a backup or another calendar app. Automatic backup 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..7cd933e 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 @@ -59,7 +59,7 @@ 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 = exportableEventsResult override fun existingUids(calendarId: Long): Set = existingUidsResult