From bff683a403fee1630b49018e93768c90b7dd7e94 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 17:55:35 +0200 Subject: [PATCH 01/15] feat: restore events from .ics file in backup section (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'Restore from .ics file' row to the Calendars backup section, next to Export. It opens a SAF document picker and routes the picked Uri into the existing import flow (parse, dedup by UID, target-calendar picker, summary) via CalendarHost's importUri — the same path an externally opened .ics already takes, so no new import machinery is needed. Closes #32. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/CalendarHost.kt | 5 +++- .../calendula/ui/calendars/CalendarsScreen.kt | 30 +++++++++++++++++++ app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 36 insertions(+), 1 deletion(-) 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 e0ce981..25cda63 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -412,7 +412,10 @@ fun CalendarHost( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), ) { - CalendarsScreen(onBack = { showCalendars = false }) + CalendarsScreen( + onBack = { showCalendars = false }, + onImport = { importUri = it }, + ) } // Import flow for an opened/received .ics file. A single event routes 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..3d82152 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 @@ -37,6 +37,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 @@ -112,6 +113,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 +133,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 +180,7 @@ fun CalendarsScreen( onConsumeError = viewModel::consumeError, backupResult = backupResult, onExportBackup = viewModel::exportBackup, + onImport = onImport, onConsumeBackupResult = viewModel::consumeBackupResult, autoBackup = autoBackup, onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled, @@ -191,6 +204,7 @@ private fun CalendarsList( onConsumeError: () -> Unit, backupResult: BackupResult?, onExportBackup: (android.net.Uri) -> Unit, + onImport: (android.net.Uri) -> Unit, onConsumeBackupResult: () -> Unit, autoBackup: AutoBackupUiState, onSetAutoBackupEnabled: (Boolean) -> Unit, @@ -223,6 +237,13 @@ private fun CalendarsList( contract = ActivityResultContracts.CreateDocument("text/calendar"), ) { uri -> uri?.let(onExportBackup) } + // 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. val pickFolder = rememberLauncherForActivityResult( @@ -316,6 +337,15 @@ private fun CalendarsList( runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } }, ) + 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( title = stringResource(R.string.calendars_auto_backup), summary = stringResource(R.string.calendars_auto_backup_hint), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c9e8df..d3272e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -428,6 +428,8 @@ Backup Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy. Export as .ics file + 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 -- 2.49.1 From f25f30832645d84169ab1b344899a927fa369579 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 18:11:45 +0200 Subject: [PATCH 02/15] fix: import target picker uses the standard grouped-list format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Add to calendar' picker rendered bare OptionCards with no calendar colour and no account grouping. Reuse the same account-grouped GroupedRow layout as the event editor's calendar picker — coloured chip per calendar, account sub-headers, a check on the selected row — so it matches the rest of the app. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/imports/ImportScreen.kt | 71 +++++++++++++++---- 1 file changed, 57 insertions(+), 14 deletions(-) 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..8a9b4d9 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState 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 @@ -38,8 +39,10 @@ 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.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.GroupedRow +import de.jeanlucmakiola.calendula.ui.common.positionOf 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 @@ -106,33 +109,73 @@ private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) { } var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) } + // Group by owning account, the same shape the event editor's calendar picker + // uses — coloured chip per calendar, a check on the selected one. + val groups = remember(state.calendars) { + state.calendars + .groupBy { + it.accountName.takeIf(String::isNotBlank) + ?: it.accountType.takeIf(String::isNotBlank) + ?: it.displayName + } + .toList() + } + Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .padding(vertical = 8.dp), ) { Text( pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(vertical = 8.dp), + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), ) Text( stringResource(R.string.import_target_header), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp), ) - state.calendars.forEach { calendar -> - OptionCard( - label = calendar.displayName, - onClick = { selected = calendar.id }, - selected = calendar.id == selected, - icon = null, + 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 == selected + 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 = { selected = calendar.id }, + ) + } + } + if (state.warnings.isNotEmpty()) { + Column( + Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + state.warnings.forEach { WarningText(it) } + } } - state.warnings.forEach { WarningText(it) } Button( onClick = { onImport(selected) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 8.dp), ) { Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size)) } -- 2.49.1 From 4a11c951ae2044dc5824a15d4335dd29753cbc3c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 18:23:39 +0200 Subject: [PATCH 03/15] refactor: one shared calendar picker with settings-page category headers Extract CalendarPickerGroups into ui/common: the calendar-manager screen's grouped-card system (device chip for local calendars, the owning app's launcher icon per synced account, colour chip + check per calendar) as a single reusable picker. Use it in both the event editor and the .ics import screen so all 'which calendar' lists match. Moves LeadingAvatar/SourceLogo/curatedSourcePackage out of CalendarsScreen into common as the shared source of truth. Drops the redundant 'Add to calendar' caption from the import picker. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/calendars/CalendarsScreen.kt | 69 +------ .../ui/common/CalendarPickerGroups.kt | 180 ++++++++++++++++++ .../calendula/ui/edit/EventEditScreen.kt | 47 +---- .../calendula/ui/imports/ImportScreen.kt | 57 +----- floret-kit | 1 + 5 files changed, 196 insertions(+), 158 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt create mode 160000 floret-kit 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 3d82152..06b214e 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 @@ -97,6 +97,9 @@ 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.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 @@ -771,66 +774,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) { @@ -965,9 +908,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/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt new file mode 100644 index 0000000..dc0103d --- /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 } } + .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..aca10af 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 @@ -122,6 +122,7 @@ 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 +1976,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 8a9b4d9..cf72b82 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 @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState 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 @@ -39,9 +38,7 @@ 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.CalendarColorChip -import de.jeanlucmakiola.calendula.ui.common.GroupedRow -import de.jeanlucmakiola.calendula.ui.common.positionOf +import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.predictiveBack /** @@ -109,18 +106,6 @@ private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) { } var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) } - // Group by owning account, the same shape the event editor's calendar picker - // uses — coloured chip per calendar, a check on the selected one. - val groups = remember(state.calendars) { - state.calendars - .groupBy { - it.accountName.takeIf(String::isNotBlank) - ?: it.accountType.takeIf(String::isNotBlank) - ?: it.displayName - } - .toList() - } - Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()) .padding(vertical = 8.dp), @@ -128,43 +113,13 @@ private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) { Text( pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 12.dp), ) - Text( - stringResource(R.string.import_target_header), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp), + CalendarPickerGroups( + calendars = state.calendars, + selectedId = selected, + onSelect = { selected = it }, ) - 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 == selected - 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 = { selected = calendar.id }, - ) - } - } if (state.warnings.isNotEmpty()) { Column( Modifier.padding(horizontal = 24.dp, vertical = 8.dp), diff --git a/floret-kit b/floret-kit new file mode 160000 index 0000000..d9e4e87 --- /dev/null +++ b/floret-kit @@ -0,0 +1 @@ +Subproject commit d9e4e877cc05450bcf121e2cb4140d0efda96f8e -- 2.49.1 From 993d74502f49af1daca26aad67dd6fc8a44e0c0b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 18:25:20 +0200 Subject: [PATCH 04/15] chore: stop tracking local floret-kit scratch dir (added by mistake) Co-Authored-By: Claude Opus 4.8 --- floret-kit | 1 - 1 file changed, 1 deletion(-) delete mode 160000 floret-kit diff --git a/floret-kit b/floret-kit deleted file mode 160000 index d9e4e87..0000000 --- a/floret-kit +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d9e4e877cc05450bcf121e2cb4140d0efda96f8e -- 2.49.1 From 8fb4767888eddb727247f4db6184a6af06f3b827 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 18:32:20 +0200 Subject: [PATCH 05/15] feat: spice up the import-complete screen Replace the plain centered text list with an M3 Expressive success state: a tonal check badge that springs in, the headline, and big-number tonal stat tiles for added / duplicate-skipped counts, with a full-width Done button. Stat tiles carry the full-sentence plurals as accessibility labels so TalkBack still reads 'Imported N events'. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/imports/ImportScreen.kt | 137 +++++++++++++++--- app/src/main/res/values/strings.xml | 2 + 2 files changed, 120 insertions(+), 19 deletions(-) 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 cf72b82..0eb0140 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,11 @@ 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.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -139,41 +157,122 @@ private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) { @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), + color = MaterialTheme.colorScheme.onSurface, ) - Text( - pluralStringResource( - R.plurals.import_done_imported, - state.summary.imported, - state.summary.imported, - ), - style = MaterialTheme.typography.bodyLarge, - ) - if (state.summary.skippedDuplicate > 0) { - Text( - pluralStringResource( - R.plurals.import_done_skipped, - state.summary.skippedDuplicate, - state.summary.skippedDuplicate, + 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, ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + 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, + ) + } } - Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) { + 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3272e7..c5ff303 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -462,6 +462,8 @@ Couldn\'t read this file. No writable calendar to import into. Create a local calendar first. Import complete + Added + Duplicates Close Some changed occurrences of recurring events were skipped. An event without a start time was skipped. -- 2.49.1 From f440d385fa5d71d797c01e40026e1fa4fda65d9b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 18:38:21 +0200 Subject: [PATCH 06/15] fix: exclude managed special-dates calendars from .ics export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contact special-dates mirror calendars (birthdays/anniversaries) are derived from contacts and re-materialise from the contact sync, so backing them up only duplicates events on restore. Skip managed calendars in exportableEvents — covers both the manual export and the auto-backup, which share this path. Co-Authored-By: Claude Opus 4.8 --- .../calendula/data/calendar/CalendarDataSource.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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..e251f38 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 @@ -613,10 +613,13 @@ class AndroidCalendarDataSource @Inject constructor( override fun exportableEvents(): 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. 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 } .associate { it.id to it.displayName } if (names.isEmpty()) return emptyList() -- 2.49.1 From 94e388734525460e8cf8e9d3779c562d352316fc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 18:39:54 +0200 Subject: [PATCH 07/15] fix: exclude managed special-dates calendars from import targets Symmetric with the export change: the contact-derived, editor-locked special-dates mirror calendars aren't a valid import destination, so drop them from the target-calendar picker. Co-Authored-By: Claude Opus 4.8 --- .../de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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..248d656 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 @@ -84,12 +84,14 @@ class ImportViewModel @Inject constructor( 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 }, ) } } -- 2.49.1 From a138e179ddfa687a9fdc4e23c7dd433f9888a131 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 18:46:24 +0200 Subject: [PATCH 08/15] feat: per-calendar export selector Tapping Export with more than one exportable calendar now opens a picker to choose which local calendars to include (all selected by default); a single calendar exports straight through as before. Threads an optional calendarIds filter through exportEvents/exportableEvents (null = all eligible), so the auto-backup path is unaffected. The backup section is now gated on there being at least one exportable (non-managed) calendar. Co-Authored-By: Claude Opus 4.8 --- .../data/calendar/CalendarDataSource.kt | 16 +++- .../data/calendar/CalendarRepository.kt | 3 +- .../data/calendar/CalendarRepositoryImpl.kt | 3 +- .../calendula/ui/calendars/CalendarsScreen.kt | 95 +++++++++++++++++-- .../ui/calendars/CalendarsViewModel.kt | 4 +- app/src/main/res/values/strings.xml | 3 + .../data/calendar/FakeCalendarDataSource.kt | 2 +- 7 files changed, 110 insertions(+), 16 deletions(-) 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 -- 2.49.1 From a98dd654a65f6433de213e0d551b6f35d487d862 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 19:07:56 +0200 Subject: [PATCH 09/15] polish: explain skipped duplicates on the import-complete screen The import de-dups by UID against the target calendar (idempotent restore), so re-importing events already present shows a low 'Added' count. Add a note under the title when any were skipped so the outcome doesn't read as broken. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/imports/ImportScreen.kt | 10 ++++++++++ app/src/main/res/values/strings.xml | 1 + 2 files changed, 11 insertions(+) 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 0eb0140..eb072af 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 @@ -48,6 +48,7 @@ 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 @@ -198,6 +199,15 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) { style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) + if (state.summary.skippedDuplicate > 0) { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.import_done_dedup_note), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } Spacer(Modifier.height(24.dp)) Row( Modifier.fillMaxWidth(), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9dcce06..c9f82e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -465,6 +465,7 @@ 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 -- 2.49.1 From 79d9e0eaa02eb3bd29907fd91c1dd19df2bfc6b3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 19:29:04 +0200 Subject: [PATCH 10/15] feat: in-app restore always uses the full import flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route by entry point, not just event count. Opening a .ics from outside still sends a single event straight to the prefilled create form (add one event, e.g. a ticket). The in-app 'Restore from .ics' button passes forceMany so even a single-event backup goes through the calendar picker + summary — its intent is 'restore a backup', not 'add this event'. Co-Authored-By: Claude Opus 4.8 --- .../de/jeanlucmakiola/calendula/ui/CalendarHost.kt | 9 ++++++++- .../calendula/ui/imports/ImportScreen.kt | 7 +++++-- .../calendula/ui/imports/ImportViewModel.kt | 11 ++++++++--- 3 files changed, 21 insertions(+), 6 deletions(-) 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 25cda63..2c938f7 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,7 @@ fun CalendarHost( ) { CalendarsScreen( onBack = { showCalendars = false }, - onImport = { importUri = it }, + onImport = { importUri = it; importForceMany = true }, ) } @@ -423,6 +429,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/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index eb072af..71d67b1 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 @@ -64,7 +64,9 @@ import de.jeanlucmakiola.calendula.ui.common.predictiveBack * 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 @@ -72,9 +74,10 @@ fun ImportScreen( uri: Uri, onClose: () -> Unit, onOpenSingle: (EventForm) -> Unit, + forceMany: Boolean = false, viewModel: ImportViewModel = hiltViewModel(), ) { - 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. 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 248d656..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,7 +82,7 @@ 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, ) -- 2.49.1 From 9f7427e72ff6bcd5074f1550b6d47834788667c6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 19:39:54 +0200 Subject: [PATCH 11/15] feat: pin import action to the top bar, fold count into title Move the multi-event import's confirm button into the app-bar actions so it's reachable without scrolling past a long calendar list, and put the count in the title ('Importing 5 events') instead of a separate 'N events in this file' line. Hoists the selected target calendar to the screen so the top-bar action can read it. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/imports/ImportScreen.kt | 56 +++++++++++++------ app/src/main/res/values/strings.xml | 5 ++ 2 files changed, 44 insertions(+), 17 deletions(-) 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 71d67b1..7e27d71 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 @@ -85,18 +85,52 @@ 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. Re-defaults to the + // first calendar when the "many" list first arrives (keyed on it), then + // holds the user's pick. + val many = state as? ImportUiState.Many + var selected by rememberSaveable(many?.calendars?.firstOrNull()?.id) { + mutableStateOf(many?.calendars?.firstOrNull()?.id) + } + 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, ), @@ -112,7 +146,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) } } @@ -120,27 +154,21 @@ 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(vertical = 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(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 12.dp), - ) CalendarPickerGroups( calendars = state.calendars, selectedId = selected, - onSelect = { selected = it }, + onSelect = onSelect, ) if (state.warnings.isNotEmpty()) { Column( @@ -150,12 +178,6 @@ private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) { state.warnings.forEach { WarningText(it) } } } - Button( - onClick = { onImport(selected) }, - modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 8.dp), - ) { - Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size)) - } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9f82e4..0609ca8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -473,6 +473,11 @@ 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. -- 2.49.1 From e967007bdc3b69ffdee4ae52138cf2bae8981213 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 19:41:51 +0200 Subject: [PATCH 12/15] fix: default import target to the first local calendar The pre-selected target was calendars.first() (raw provider order), which could land on a synced calendar mid-list while the picker shows local calendars first. Default to the first local calendar so the checkmark lines up with the top row; fall back to the first calendar when none are local. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/imports/ImportScreen.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 7e27d71..42e417d 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 @@ -86,13 +86,16 @@ fun ImportScreen( } // Hoisted target calendar so the always-visible top-bar Import action can - // read it without the user scrolling to a bottom button. Re-defaults to the - // first calendar when the "many" list first arrives (keyed on it), then - // holds the user's pick. + // 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 - var selected by rememberSaveable(many?.calendars?.firstOrNull()?.id) { - mutableStateOf(many?.calendars?.firstOrNull()?.id) + val defaultTarget = many?.calendars?.let { cals -> + (cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id } + var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) } Scaffold( modifier = Modifier -- 2.49.1 From df426bb8dfa0637624e38c0125953ac89574f717 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 20:34:15 +0200 Subject: [PATCH 13/15] fix: scope import ViewModel per-uri so a second import re-parses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImportScreen has no nav backstack, so an unkeyed hiltViewModel() resolved to the Activity's ViewModelStore and was retained across imports. Its one-shot `load` guard then showed the *previous* file's parsed state on the next import — trivially reachable now that the in-app Restore button lets you export→restore or restore twice in one session (worst case: the picker still holds file A, so tapping Import writes A's events after you picked B). Keying the VM by the file uri hands each distinct file a fresh VM (fresh Loading state); the same uri (rotation) reuses it and holds the result. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanlucmakiola/calendula/ui/imports/ImportScreen.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 42e417d..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 @@ -75,7 +75,13 @@ fun ImportScreen( onClose: () -> Unit, onOpenSingle: (EventForm) -> Unit, forceMany: Boolean = false, - viewModel: ImportViewModel = hiltViewModel(), + // 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, forceMany) } val state by viewModel.state.collectAsStateWithLifecycle() -- 2.49.1 From 2ce79942c4a2de7ab7b2ee36016bcee4b6e51b63 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 20:34:35 +0200 Subject: [PATCH 14/15] fix: broaden restore availability and stabilize the export picker - Restore is import, not export: offer it whenever any writable, non- managed calendar exists (local or synced), not only when there is a local calendar to back up. Previously the row lived inside the export-gated block and vanished for users with only a writable synced calendar, despite import supporting that target. - Export-picker selection now uses rememberSaveable and is no longer keyed on the observer-driven calendars list, so a background provider re-emit (sync/recolor) can't silently reset the user's de-selections, and the choice survives rotation. - Shared calendar picker: restore the displayName fallback for a synced calendar whose account name and type are both blank (was grouping them under an empty header). - Drop imports left dead by the CalendarPickerGroups extraction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/calendars/CalendarsScreen.kt | 37 +++++++++++++++---- .../ui/common/CalendarPickerGroups.kt | 2 +- .../calendula/ui/edit/EventEditScreen.kt | 2 - app/src/main/res/values/strings.xml | 1 + 4 files changed, 31 insertions(+), 11 deletions(-) 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 7a89d24..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 @@ -68,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 @@ -75,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 @@ -87,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 @@ -243,7 +238,7 @@ private fun CalendarsList( val createBackup = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("text/calendar"), ) { uri -> uri?.let { onExportBackup(it, null) } } - var showExportPicker by remember { mutableStateOf(false) } + 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 @@ -333,6 +328,9 @@ private fun CalendarsList( // 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)) @@ -388,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)) @@ -475,7 +486,17 @@ private fun ExportCalendarPicker( onExport: (android.net.Uri, Set?) -> Unit, onDismiss: () -> Unit, ) { - var selected by remember(calendars) { + // 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( 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 index dc0103d..758bc0c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -50,7 +50,7 @@ fun ColumnScope.CalendarPickerGroups( val local = remember(calendars) { calendars.filter { it.isLocal } } val syncedGroups = remember(calendars) { calendars.filterNot { it.isLocal } - .groupBy { it.accountName.ifBlank { it.accountType } } + .groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } } .toList() } 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 aca10af..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,6 @@ 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0609ca8..19639cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -431,6 +431,7 @@ 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 -- 2.49.1 From f0cc35f2ce794c62a1ed9201c4fea82ea313a2c0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 20:34:35 +0200 Subject: [PATCH 15/15] test: cover the export calendar-id plumbing The fake now records the calendarIds it receives; two repository tests assert exportEvents forwards a chosen subset and defaults to null (all eligible calendars), closing the coverage gap for the per-calendar export selector. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendar/CalendarRepositoryImplTest.kt | 24 +++++++++++++++++++ .../data/calendar/FakeCalendarDataSource.kt | 7 +++++- 2 files changed, 30 insertions(+), 1 deletion(-) 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 7cd933e..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(calendarIds: Set?): List = exportableEventsResult + override fun exportableEvents(calendarIds: Set?): List { + lastExportableEventsCalendarIds = calendarIds + return exportableEventsResult + } override fun existingUids(calendarId: Long): Set = existingUidsResult -- 2.49.1