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/22] 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 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/22] 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)) } 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/22] 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 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/22] 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 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/22] 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. 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/22] 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() 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/22] 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 }, ) } } 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/22] 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 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/22] 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 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/22] 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, ) 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/22] 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. 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/22] 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 From 31f51554f95e79550a260eecc2b7d693149a1dc9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 19:51:49 +0200 Subject: [PATCH 13/22] feat: open Day view when tapping a date header in Week/Agenda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a date header in the Week (day-of-week column) and Agenda (sticky section) views now drills into that date in Day view, mirroring the Agenda widget's header behaviour. Both reuse the existing onOpenDay callback (pendingDayIso + drillToDay) that Month already used, so the back stack lands on Day with the tapped view as its parent. Month already navigated on any cell tap (the transparent tap layer sits above the day number), so no change was needed there — all four views now behave consistently. Closes #37 Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/CalendarHost.kt | 2 ++ .../calendula/ui/agenda/AgendaScreen.kt | 17 ++++++++++++++--- .../calendula/ui/week/WeekScreen.kt | 18 +++++++++++++++--- 3 files changed, 31 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 e0ce981..3e66a43 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -284,6 +284,7 @@ fun CalendarHost( CalendarView.Week -> WeekScreen( selectedView = currentView, onSelectView = onSelectView, + onOpenDay = onOpenDay, onEventClick = onEventClick, onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, @@ -315,6 +316,7 @@ fun CalendarHost( CalendarView.Agenda -> AgendaScreen( selectedView = currentView, onSelectView = onSelectView, + onOpenDay = onOpenDay, onEventClick = onEventClick, onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 8286ee8..06c462a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -93,6 +93,7 @@ private val zone = TimeZone.currentSystemDefault() fun AgendaScreen( selectedView: CalendarView, onSelectView: (CalendarView) -> Unit, + onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, @@ -191,6 +192,7 @@ fun AgendaScreen( pastDisplay = pastDisplay, onRetry = viewModel::goToToday, onEventClick = onEventClick, + onOpenDay = onOpenDay, modifier = Modifier .weight(1f) .fillMaxWidth(), @@ -289,6 +291,7 @@ private fun AgendaContent( pastDisplay: PastEventDisplay, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { when (state) { @@ -318,6 +321,7 @@ private fun AgendaContent( dimPast = pastDisplay == PastEventDisplay.DIM, now = now, onEventClick = onEventClick, + onOpenDay = onOpenDay, modifier = modifier, ) } @@ -333,6 +337,7 @@ private fun AgendaList( dimPast: Boolean, now: Instant, onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -342,7 +347,7 @@ private fun AgendaList( ) { days.forEach { day -> stickyHeader(key = "header-${day.date}") { - AgendaDayHeader(date = day.date, today = today) + AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay) } itemsIndexed( items = day.events, @@ -362,10 +367,16 @@ private fun AgendaList( } @Composable -private fun AgendaDayHeader(date: LocalDate, today: LocalDate) { +private fun AgendaDayHeader( + date: LocalDate, + today: LocalDate, + onOpenDay: (LocalDate) -> Unit, +) { Surface( color = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenDay(date) }, ) { Text( text = agendaDayLabel(date, today), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index c40ab29..3af71f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -128,6 +128,7 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp { fun WeekScreen( selectedView: CalendarView, onSelectView: (CalendarView) -> Unit, + onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, @@ -249,6 +250,7 @@ fun WeekScreen( onSwipePrev = goPrev, onRetry = jumpToToday, onEventClick = onEventClick, + onOpenDay = onOpenDay, onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, modifier = Modifier .padding(innerPadding) @@ -268,6 +270,7 @@ private fun WeekContent( onSwipePrev: () -> Unit, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { @@ -342,6 +345,7 @@ private fun WeekContent( scrollState = scrollState, allDayHeight = allDayHeight, onEventClick = onEventClick, + onOpenDay = onOpenDay, onCreateAt = onCreateAt, ) } @@ -355,6 +359,7 @@ private fun WeekSuccess( scrollState: ScrollState, allDayHeight: Dp, onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { @@ -363,7 +368,7 @@ private fun WeekSuccess( .fillMaxWidth() .background(topSectionColor), ) { - WeekDayHeader(days = state.days, today = state.today) + WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay) AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) } // Breathing room between the (colour-shifting) top section and the @@ -427,7 +432,11 @@ private fun WeekTopBar( } @Composable -private fun WeekDayHeader(days: List, today: LocalDate) { +private fun WeekDayHeader( + days: List, + today: LocalDate, + onOpenDay: (LocalDate) -> Unit, +) { val locale = currentLocale() val weekStart = days.first() val weekNumber = remember(weekStart) { @@ -453,7 +462,10 @@ private fun WeekDayHeader(days: List, today: LocalDate) { val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) val isToday = date == today Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .clickable { onOpenDay(date) }, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( From df426bb8dfa0637624e38c0125953ac89574f717 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 20:34:15 +0200 Subject: [PATCH 14/22] 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() From 2ce79942c4a2de7ab7b2ee36016bcee4b6e51b63 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 20:34:35 +0200 Subject: [PATCH 15/22] 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 From f0cc35f2ce794c62a1ed9201c4fea82ea313a2c0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 20:34:35 +0200 Subject: [PATCH 16/22] 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 From b449fff77cab430f0cec49b307f272c5918d836c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 21:34:40 +0200 Subject: [PATCH 17/22] =?UTF-8?q?release:=20v2.14.0=20=E2=80=94=20Day=20vi?= =?UTF-8?q?ew=20on=20date-header=20tap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump versionName to 2.14.0 (versionCode 21400) and cut the changelog for the day-view-on-date-header-tap feature (#37). Merging this to main triggers the release pipeline. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++++++ app/build.gradle.kts | 4 ++-- fastlane/metadata/android/en-US/changelogs/21400.txt | 7 +++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/21400.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3526c95..cf85561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.14.0] — 2026-07-06 + +### Added +- Tap a date header to open that day. In Week and Agenda view, tapping a date + header now opens that date in Day view — the same drill-in that Month view and + the agenda widget already offered, so every view behaves the same way. It makes + jumping to a specific day quicker: switch to Week, swipe to the week you want, + then tap the date to open it. Thanks to @ptab for the suggestion ([#37]). + ## [2.13.1] — 2026-07-06 ### Added @@ -838,3 +847,4 @@ automatically, with zero telemetry and no internet permission. [#29]: https://codeberg.org/jlmakiola/calendula/issues/29 [#30]: https://codeberg.org/jlmakiola/calendula/issues/30 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 +[#37]: https://codeberg.org/jlmakiola/calendula/issues/37 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 38a3422..dc638b4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 21301 - versionName = "2.13.1" + versionCode = 21400 + versionName = "2.14.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/fastlane/metadata/android/en-US/changelogs/21400.txt b/fastlane/metadata/android/en-US/changelogs/21400.txt new file mode 100644 index 0000000..e6f8195 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21400.txt @@ -0,0 +1,7 @@ +### Added +- Tap a date header to open that day. In Week and Agenda view, tapping a date + header now opens that date in Day view — the same drill-in that Month view and + the agenda widget already offered, so every view behaves the same way. It makes + jumping to a specific day quicker: switch to Week, swipe to the week you want, + then tap the date to open it. Thanks to @ptab for the suggestion ([#37]). + From a514b8b50656a9af1697d4965f97de2ba10e6b62 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 22:02:44 +0200 Subject: [PATCH 18/22] feat: show calendar-week numbers in Month view (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in left gutter to the Month grid showing the ISO calendar-week number, gated by a new "Week numbers" display setting (default off). The number is computed on each row's first day — the same basis as the Week view's badge — so the two views agree, and rendered as a low-emphasis onSurfaceVariant label so it recedes across all six rows rather than competing with the event bars. The weekday header reserves a matching gutter so the day columns stay aligned. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 14 + .../calendula/ui/month/MonthScreen.kt | 280 +++++++++++------- .../calendula/ui/month/MonthViewModel.kt | 8 + .../calendula/ui/settings/SettingsScreen.kt | 12 + .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 18 +- app/src/main/res/values/strings.xml | 2 + .../calendula/data/prefs/SettingsPrefsTest.kt | 8 + 8 files changed, 231 insertions(+), 113 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 7f0f122..1553778 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -200,6 +200,19 @@ class SettingsPrefs @Inject constructor( store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled } } + /** + * Whether the Month grid shows the calendar-week (ISO) number in a left + * gutter (#25). Defaults to OFF — users opt in, since it narrows the day + * cells slightly. The Week view shows its number unconditionally. + */ + val showWeekNumbers: Flow = store.data.map { prefs -> + prefs[SHOW_WEEK_NUMBERS_KEY] ?: false + } + + suspend fun setShowWeekNumbers(enabled: Boolean) { + store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled } + } + /** * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to * [AgendaRange.Month] — a month of upcoming events. Independent of the @@ -659,6 +672,7 @@ class SettingsPrefs @Inject constructor( internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") + internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 3413ce7..8aa3ace 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -108,6 +108,7 @@ fun MonthScreen( val month by viewModel.month.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() + val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle() // The instant before which an event counts as completed, or null when dimming // is off. derivedStateOf keeps the per-minute "now" from recomposing the // screen while the setting is off (it stays null regardless of the tick). @@ -211,11 +212,12 @@ fun MonthScreen( .padding(innerPadding) .fillMaxSize(), ) { - WeekdayHeader(weekStart = weekStart) + WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { MonthContent( state = state, slideDir = slideDir, + showWeekNumbers = showWeekNumbers, onSwipeNext = goNext, onSwipePrev = goPrev, onRetry = jumpToToday, @@ -231,6 +233,7 @@ fun MonthScreen( private fun MonthContent( state: MonthUiState, slideDir: Int, + showWeekNumbers: Boolean, onSwipeNext: () -> Unit, onSwipePrev: () -> Unit, onRetry: () -> Unit, @@ -276,6 +279,7 @@ private fun MonthContent( is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is MonthUiState.Success -> MonthGrid( state = s, + showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, ) } @@ -325,7 +329,7 @@ private fun MonthTopBar( } @Composable -private fun WeekdayHeader(weekStart: DayOfWeek) { +private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { val locale = currentLocale() val days = remember(weekStart, locale) { (0 until 7).map { offset -> @@ -337,6 +341,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) { .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 4.dp), ) { + // Reserve the gutter so the weekday labels stay over their day columns. + if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER)) days.forEach { dow -> val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1) @@ -354,6 +360,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) { private val EVENT_ROW_HEIGHT = 20.dp private val DAY_NUMBER_HEIGHT = 22.dp +/** Width of the optional left gutter holding the calendar-week number (#25). */ +private val WEEK_NUMBER_GUTTER = 24.dp private val DAY_NUMBER_GAP = 4.dp private val CELL_TOP_PADDING = 6.dp private val CELL_GAP = 2.dp @@ -363,6 +371,7 @@ private const val MAX_EVENT_ROWS = 3 @Composable private fun MonthGrid( state: MonthUiState.Success, + showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, ) { Column( @@ -376,6 +385,7 @@ private fun MonthGrid( week = week, today = state.today, month = state.month, + showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, modifier = Modifier .fillMaxWidth() @@ -397,6 +407,7 @@ private fun MonthWeekRow( week: MonthWeek, today: LocalDate, month: YearMonth, + showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { @@ -404,135 +415,184 @@ private fun MonthWeekRow( val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) - BoxWithConstraints(modifier) { - val colW = maxWidth / 7 - - // Per-day background pills — same surfaceContainer rounded surface the - // week/day views use, so the three views share one visual language. - // Spanning bars draw on top of these, bridging cells, so they still read - // as one continuous event. - Row(Modifier.matchParentSize()) { - week.days.forEach { d -> - val inMonth = d.month == month.month && d.year == month.year - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = CELL_GAP, vertical = 1.dp) - .background( - color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer - else MaterialTheme.colorScheme.surfaceContainerLow, - shape = CELL_SHAPE, - ), - ) - } - } - - Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { - Row(Modifier.fillMaxWidth()) { - week.days.forEach { d -> - DayNumberCell( - date = d, - isToday = d == today, - inMonth = d.month == month.month && d.year == month.year, - modifier = Modifier.weight(1f), - ) - } - } - // Breathing room between the day number (and today's circle) and the - // first event row. - Spacer(Modifier.height(DAY_NUMBER_GAP)) - Box( + Row(modifier) { + // Optional calendar-week gutter, sized so the seven day columns below + // divide the remaining width — the absolute bar offsets stay correct + // because they're measured inside the grid box, not the whole row. + if (showWeekNumbers) { + WeekNumberGutter( + weekStart = week.days.first(), modifier = Modifier - .fillMaxWidth() - .weight(1f) - .clipToBounds(), - ) { - // Spanning bars on their shared lanes. - week.spans.filter { it.lane < shownLanes }.forEach { span -> - val cols = span.endCol - span.startCol + 1 - MonthBar( - event = span.event, - dark = dark, - continuesLeft = span.continuesLeft, - continuesRight = span.continuesRight, - modifier = Modifier - .offset( - x = colW * span.startCol, - y = EVENT_ROW_HEIGHT * span.lane, - ) - .width(colW * cols) - .height(EVENT_ROW_HEIGHT) - .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + .width(WEEK_NUMBER_GUTTER) + .fillMaxHeight(), + ) + } + BoxWithConstraints( + Modifier + .weight(1f) + .fillMaxHeight(), + ) { + val colW = maxWidth / 7 + + // Per-day background pills — same surfaceContainer rounded surface the + // week/day views use, so the three views share one visual language. + // Spanning bars draw on top of these, bridging cells, so they still read + // as one continuous event. + Row(Modifier.matchParentSize()) { + week.days.forEach { d -> + val inMonth = d.month == month.month && d.year == month.year + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .background( + color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer + else MaterialTheme.colorScheme.surfaceContainerLow, + shape = CELL_SHAPE, + ), ) } - // Single-day timed pills + overflow, per column. Pills fill the - // lane slots no bar occupies on THIS day (top-most first), so a - // bar-free day isn't pushed down by a multi-day event that only - // sits on other days of the week. - week.days.forEachIndexed { col, d -> - val timed = week.timedByDay[d].orEmpty() - val occupied = week.spans - .filter { it.lane < shownLanes && col in it.startCol..it.endCol } - .map { it.lane } - .toSet() - val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied } - val pillsShown = timed.take(freeSlots.size) - pillsShown.forEachIndexed { i, ev -> + } + + Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { + Row(Modifier.fillMaxWidth()) { + week.days.forEach { d -> + DayNumberCell( + date = d, + isToday = d == today, + inMonth = d.month == month.month && d.year == month.year, + modifier = Modifier.weight(1f), + ) + } + } + // Breathing room between the day number (and today's circle) and the + // first event row. + Spacer(Modifier.height(DAY_NUMBER_GAP)) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clipToBounds(), + ) { + // Spanning bars on their shared lanes. + week.spans.filter { it.lane < shownLanes }.forEach { span -> + val cols = span.endCol - span.startCol + 1 MonthBar( - event = ev, + event = span.event, dark = dark, - continuesLeft = false, - continuesRight = false, + continuesLeft = span.continuesLeft, + continuesRight = span.continuesRight, modifier = Modifier .offset( - x = colW * col, - y = EVENT_ROW_HEIGHT * freeSlots[i], + x = colW * span.startCol, + y = EVENT_ROW_HEIGHT * span.lane, ) - .width(colW) + .width(colW * cols) .height(EVENT_ROW_HEIGHT) .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), ) } - val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size - if (hidden > 0) { - val hiddenColors = buildList { - week.spans - .filter { it.lane >= shownLanes && col in it.startCol..it.endCol } - .forEach { add(it.event.color) } - timed.drop(pillsShown.size).forEach { add(it.color) } - }.distinct().take(3) - OverflowDots( - colors = hiddenColors, - extra = hidden - hiddenColors.size, - dark = dark, - modifier = Modifier - .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) - .width(colW) - .padding(horizontal = 3.dp), - ) + // Single-day timed pills + overflow, per column. Pills fill the + // lane slots no bar occupies on THIS day (top-most first), so a + // bar-free day isn't pushed down by a multi-day event that only + // sits on other days of the week. + week.days.forEachIndexed { col, d -> + val timed = week.timedByDay[d].orEmpty() + val occupied = week.spans + .filter { it.lane < shownLanes && col in it.startCol..it.endCol } + .map { it.lane } + .toSet() + val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied } + val pillsShown = timed.take(freeSlots.size) + pillsShown.forEachIndexed { i, ev -> + MonthBar( + event = ev, + dark = dark, + continuesLeft = false, + continuesRight = false, + modifier = Modifier + .offset( + x = colW * col, + y = EVENT_ROW_HEIGHT * freeSlots[i], + ) + .width(colW) + .height(EVENT_ROW_HEIGHT) + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + ) + } + val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size + if (hidden > 0) { + val hiddenColors = buildList { + week.spans + .filter { it.lane >= shownLanes && col in it.startCol..it.endCol } + .forEach { add(it.event.color) } + timed.drop(pillsShown.size).forEach { add(it.color) } + }.distinct().take(3) + OverflowDots( + colors = hiddenColors, + extra = hidden - hiddenColors.size, + dark = dark, + modifier = Modifier + .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) + .width(colW) + .padding(horizontal = 3.dp), + ) + } } } } - } - // Tap layer: in month view a tap on any day opens that day. Padded and - // clipped to the background pill so the ripple matches it. - Row(Modifier.matchParentSize()) { - week.days.forEach { d -> - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = CELL_GAP, vertical = 1.dp) - .clip(CELL_SHAPE) - .clickable { onOpenDay(d) }, - ) + // Tap layer: in month view a tap on any day opens that day. Padded and + // clipped to the background pill so the ripple matches it. + Row(Modifier.matchParentSize()) { + week.days.forEach { d -> + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .clip(CELL_SHAPE) + .clickable { onOpenDay(d) }, + ) + } } } } } +/** + * Left-gutter calendar-week number (#25), aligned with the day-number band. The + * ISO week-of-week-based-year computed on the row's first day — the same basis + * as the Week view's badge — so the two views agree. A low-emphasis label rather + * than a filled badge: it repeats on all six rows, so it must recede. + */ +@Composable +private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { + val weekNumber = remember(weekStart) { + java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day) + .get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR) + } + val label = stringResource(R.string.week_number_label) + Column( + modifier = modifier + .padding(top = CELL_TOP_PADDING) + .semantics { contentDescription = "$label $weekNumber" }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier.height(DAY_NUMBER_HEIGHT), + contentAlignment = Alignment.Center, + ) { + Text( + text = weekNumber.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun DayNumberCell( date: LocalDate, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 2804a6b..de51d4e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -67,6 +67,14 @@ class MonthViewModel @Inject constructor( initialValue = false, ) + /** Whether to show the calendar-week number gutter (#25; display only). */ + val showWeekNumbers: StateFlow = settingsPrefs.showWeekNumbers + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = false, + ) + private val todayDate: LocalDate get() = Clock.System.now().toLocalDateTime(zone).date diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 29b4e1f..501db1f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -613,6 +613,18 @@ private fun AppearanceScreen( position = Position.Middle, onClick = { showWeekStart = true }, ) + GroupedRow( + title = stringResource(R.string.settings_week_numbers), + summary = stringResource(R.string.settings_week_numbers_summary), + position = Position.Middle, + trailing = { + Switch( + checked = state.showWeekNumbers, + onCheckedChange = viewModel::setShowWeekNumbers, + ) + }, + onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) }, + ) GroupedRow( title = stringResource(R.string.settings_time_format), summary = timeFormatLabel(state.timeFormat), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index f45132d..eb8d531 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -33,6 +33,8 @@ data class SettingsUiState( val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW, /** Whether the month/week grids fade events that have already finished. */ val dimCompletedEvents: Boolean = false, + /** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */ + val showWeekNumbers: Boolean = false, /** How far ahead the in-app Agenda screen shows events (v2.11). */ val agendaScreenRange: AgendaRange = AgendaRange.Month, /** How far ahead the agenda widget shows events (v2.11). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 61f57bd..f1a879a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -116,9 +116,15 @@ class SettingsViewModel @Inject constructor( prefs.agendaScreenRange, prefs.agendaWidgetRange, prefs.timeFormat, - prefs.showHourLines, - ) { view, screenRange, widgetRange, timeFormat, showHourLines -> - ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines) + // Two grid-display toggles folded into one flow so they fit this + // group — the outer combine is already at its five-arg limit. + combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair), + ) { view, screenRange, widgetRange, timeFormat, gridToggles -> + ViewSettings( + view, screenRange, widgetRange, timeFormat, + showHourLines = gridToggles.first, + showWeekNumbers = gridToggles.second, + ) }, combine( prefs.agendaShowRangeBar, @@ -140,6 +146,7 @@ class SettingsViewModel @Inject constructor( agendaWidgetRange = views.agendaWidgetRange, timeFormat = views.timeFormat, showHourLines = views.showHourLines, + showWeekNumbers = views.showWeekNumbers, agendaShowRangeBar = misc.showRangeBar, autofocusEventTitle = misc.autofocusEventTitle, pastEventDisplay = misc.pastEventDisplay, @@ -212,6 +219,7 @@ class SettingsViewModel @Inject constructor( val agendaWidgetRange: AgendaRange, val timeFormat: TimeFormatPref, val showHourLines: Boolean, + val showWeekNumbers: Boolean, ) private data class MiscSettings( @@ -398,6 +406,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setShowHourLines(enabled) } } + fun setShowWeekNumbers(enabled: Boolean) { + viewModelScope.launch { prefs.setShowWeekNumbers(enabled) } + } + fun setPastEventDisplay(mode: PastEventDisplay) { viewModelScope.launch { prefs.setPastEventDisplay(mode) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19639cb..06e5b19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -287,6 +287,8 @@ Couldn\'t read that file as a font Week starts on Automatic + Week numbers + Show calendar-week numbers in month view Time format Automatic 12-hour (2:00 PM) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 8fb656c..41ea2a6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -95,6 +95,14 @@ class SettingsPrefsTest { assertThat(prefs.showHourLines.first()).isTrue() } + @Test + fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.showWeekNumbers.first()).isFalse() + prefs.setShowWeekNumbers(true) + assertThat(prefs.showWeekNumbers.first()).isTrue() + } + @Test fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) From 8b22e1b2af94dcbb6e00bec28d5557762ba0c630 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 22:11:02 +0200 Subject: [PATCH 19/22] refactor: share WeekNumberBadge; use it in Month gutter, centered Extract the Week view's calendar-week badge into a shared ui/common component and reuse it for the Month grid's week-number gutter, so the two views show week numbers in the exact same format. The gutter now centres the badge vertically in each row (was pinned to the day-number band) and is widened to seat the badge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/common/WeekNumberBadge.kt | 38 +++++++++++++++++++ .../calendula/ui/month/MonthScreen.kt | 34 ++++++----------- .../calendula/ui/week/WeekScreen.kt | 21 +--------- 3 files changed, 51 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt new file mode 100644 index 0000000..771adf9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R + +/** + * Calendar-week badge — a filled tonal chip with a bold number, deliberately set + * apart from the surrounding day numbers. Shared by the Week and Month views so + * the week-of-year indicator reads identically wherever it appears. + */ +@Composable +fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { + val label = stringResource(R.string.week_number_label) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = modifier.semantics { contentDescription = "$label $weekNumber" }, + ) { + Text( + text = weekNumber.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 8aa3ace..f73a5f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -73,6 +73,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill +import de.jeanlucmakiola.calendula.ui.common.WeekNumberBadge import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion @@ -360,8 +361,9 @@ private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { private val EVENT_ROW_HEIGHT = 20.dp private val DAY_NUMBER_HEIGHT = 22.dp -/** Width of the optional left gutter holding the calendar-week number (#25). */ -private val WEEK_NUMBER_GUTTER = 24.dp +/** Width of the optional left gutter holding the calendar-week badge (#25); wide + * enough to seat the shared [WeekNumberBadge] with a little breathing room. */ +private val WEEK_NUMBER_GUTTER = 40.dp private val DAY_NUMBER_GAP = 4.dp private val CELL_TOP_PADDING = 6.dp private val CELL_GAP = 2.dp @@ -562,10 +564,10 @@ private fun MonthWeekRow( } /** - * Left-gutter calendar-week number (#25), aligned with the day-number band. The - * ISO week-of-week-based-year computed on the row's first day — the same basis - * as the Week view's badge — so the two views agree. A low-emphasis label rather - * than a filled badge: it repeats on all six rows, so it must recede. + * Left-gutter calendar-week indicator (#25), centred vertically in the row. Uses + * the shared [WeekNumberBadge] so it's identical to the Week view's, and computes + * the ISO week-of-week-based-year on the row's first day — the same basis — so the + * two views always agree. */ @Composable private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { @@ -573,23 +575,11 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day) .get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR) } - val label = stringResource(R.string.week_number_label) - Column( - modifier = modifier - .padding(top = CELL_TOP_PADDING) - .semantics { contentDescription = "$label $weekNumber" }, - horizontalAlignment = Alignment.CenterHorizontally, + Box( + modifier = modifier, + contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier.height(DAY_NUMBER_HEIGHT), - contentAlignment = Alignment.Center, - ) { - Text( - text = weekNumber.toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + WeekNumberBadge(weekNumber = weekNumber) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 3af71f0..a2a59de 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -87,6 +87,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill +import de.jeanlucmakiola.calendula.ui.common.WeekNumberBadge import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion @@ -507,26 +508,6 @@ private fun WeekDayHeader( } } -/** Calendar-week badge shown in the header gutter, deliberately set apart with a - * filled box and bold number. */ -@Composable -private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { - val label = stringResource(R.string.week_number_label) - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = modifier.semantics { contentDescription = "$label $weekNumber" }, - ) { - Text( - text = weekNumber.toString(), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) - } -} - @Composable private fun AllDayStrip( state: WeekUiState.Success, From 1f050c2be948b8bb71edb54ba1a17f4bc5d63518 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 22:24:11 +0200 Subject: [PATCH 20/22] feat: make Month week-number a full-height cell like the day cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per on-device review, render the week number as a full-height tonal pill mirroring the day cells' geometry (secondaryContainer tint, same rounded shape and gap), with the number centred — so the gutter reads as part of the grid rather than a floating chip. This diverges from the Week view's small header chip, so revert the shared-badge extraction: restore WeekScreen's private badge and drop ui/common/WeekNumberBadge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/common/WeekNumberBadge.kt | 38 ------------------- .../calendula/ui/month/MonthScreen.kt | 27 ++++++++----- .../calendula/ui/week/WeekScreen.kt | 21 +++++++++- 3 files changed, 38 insertions(+), 48 deletions(-) delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt deleted file mode 100644 index 771adf9..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt +++ /dev/null @@ -1,38 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.common - -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import de.jeanlucmakiola.calendula.R - -/** - * Calendar-week badge — a filled tonal chip with a bold number, deliberately set - * apart from the surrounding day numbers. Shared by the Week and Month views so - * the week-of-year indicator reads identically wherever it appears. - */ -@Composable -fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { - val label = stringResource(R.string.week_number_label) - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = modifier.semantics { contentDescription = "$label $weekNumber" }, - ) { - Text( - text = weekNumber.toString(), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index f73a5f1..14bf928 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -73,7 +73,6 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.WeekNumberBadge import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion @@ -361,8 +360,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { private val EVENT_ROW_HEIGHT = 20.dp private val DAY_NUMBER_HEIGHT = 22.dp -/** Width of the optional left gutter holding the calendar-week badge (#25); wide - * enough to seat the shared [WeekNumberBadge] with a little breathing room. */ +/** Width of the optional left calendar-week gutter (#25); narrow, since it only + * seats a one- or two-digit week number in a full-height tonal pill. */ private val WEEK_NUMBER_GUTTER = 40.dp private val DAY_NUMBER_GAP = 4.dp private val CELL_TOP_PADDING = 6.dp @@ -564,10 +563,11 @@ private fun MonthWeekRow( } /** - * Left-gutter calendar-week indicator (#25), centred vertically in the row. Uses - * the shared [WeekNumberBadge] so it's identical to the Week view's, and computes - * the ISO week-of-week-based-year on the row's first day — the same basis — so the - * two views always agree. + * Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the + * day cells' geometry, set apart by the secondaryContainer tint (matching the + * Week view's badge), with the ISO week number centred like a day number. The + * week is computed on the row's first day — the same basis as the Week view — so + * the two agree. */ @Composable private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { @@ -575,11 +575,20 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day) .get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR) } + val label = stringResource(R.string.week_number_label) Box( - modifier = modifier, + modifier = modifier + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE) + .semantics { contentDescription = "$label $weekNumber" }, contentAlignment = Alignment.Center, ) { - WeekNumberBadge(weekNumber = weekNumber) + Text( + text = weekNumber.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index a2a59de..3af71f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -87,7 +87,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.WeekNumberBadge import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion @@ -508,6 +507,26 @@ private fun WeekDayHeader( } } +/** Calendar-week badge shown in the header gutter, deliberately set apart with a + * filled box and bold number. */ +@Composable +private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { + val label = stringResource(R.string.week_number_label) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = modifier.semantics { contentDescription = "$label $weekNumber" }, + ) { + Text( + text = weekNumber.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + @Composable private fun AllDayStrip( state: WeekUiState.Success, From 3c73028c8065e3bb6bd1aff6c952c561c06f6461 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 22:28:31 +0200 Subject: [PATCH 21/22] Wrap long event titles in the edit screen (#33) The edit-screen title field was single-line, so long titles scrolled off one line instead of wrapping. Make it multi-line so it wraps and grows vertically, matching the detail screen and Google Calendar. A title is still one logical line: strip any newline the IME's Enter key or a paste would introduce in setTitle, so no line break reaches the provider's TITLE column. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt | 6 +++++- .../jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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..fe7182f 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 @@ -520,13 +520,17 @@ private fun EventEditContent( .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), ) { // Title: borderless headline, mirroring the detail screen's title + - // accent bar instead of a boxed Material text field. + // accent bar instead of a boxed Material text field. Multi-line so long + // titles wrap instead of scrolling off one line (#33); newlines are + // stripped in setTitle, so this stays a single logical line — the Enter + // key just has no effect. InlineField( value = form.title, onValueChange = viewModel::setTitle, placeholder = stringResource(R.string.event_edit_title_hint), textStyle = MaterialTheme.typography.headlineMedium .copy(fontWeight = FontWeight.SemiBold), + singleLine = false, enabled = !locked, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index fe1ec73..e2da425 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -336,7 +336,11 @@ class EventEditViewModel @Inject constructor( _revealed.value = _revealed.value + field } - fun setTitle(value: String) = update { it.copy(title = value) } + // The title field wraps (multi-line) so long titles stay visible (#33), but + // a title is one logical line: drop any newline the IME's Enter key or a + // paste would introduce, so it never reaches the provider's TITLE column. + fun setTitle(value: String) = + update { it.copy(title = value.replace("\n", "").replace("\r", "")) } fun setLocation(value: String) = update { it.copy(location = value) } fun setDescription(value: String) = update { it.copy(description = value) } fun setAllDay(value: Boolean) { From 8536774522be35a02f13b69fa6811f6a1365afb2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 7 Jul 2026 09:23:57 +0200 Subject: [PATCH 22/22] feat: surface Simplified Chinese; complete 2.14.0 changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add zh-CN to locales_config.xml so the community Simplified Chinese translation (values-zh-rCN, already committed via Weblate, ~27%) is selectable in the in-app language picker and Android's per-app-language settings. Untranslated strings fall back to English. Fill in the 2.14.0 changelog, which only documented #37: add the three feature PRs that also landed on this branch — .ics restore + per-calendar export (#32), Month week numbers (#25), edit-screen title wrapping (#33) — plus a note for the new Chinese translation, the missing [#N] link refs, and the re-synced fastlane en-US changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 +++++++++++++++++++ app/src/main/res/xml/locales_config.xml | 1 + .../android/en-US/changelogs/21400.txt | 23 ++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf85561..d8da9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.14.0] — 2026-07-06 ### Added +- Restore events from a backup file. The backup section of Settings can now read + events back **in** from an `.ics` file, not just write one out: pick a file, + choose which calendar to import into, and Calendula adds the events — skipping + any that are already there and telling you how many it skipped. Export gained a + per-calendar selector at the same time, so you can back up just the calendars + you pick instead of everything at once ([#32]). +- Week numbers in Month view. A new **Week numbers** setting (off by default) + adds a slim gutter down the left of the Month grid showing the calendar-week + number for each row, sized to match the day cells. Handy if you plan or refer + to dates by week number ([#25]). - Tap a date header to open that day. In Week and Agenda view, tapping a date header now opens that date in Day view — the same drill-in that Month view and the agenda widget already offered, so every view behaves the same way. It makes jumping to a specific day quicker: switch to Week, swipe to the week you want, then tap the date to open it. Thanks to @ptab for the suggestion ([#37]). +- An early Simplified Chinese translation. Calendula has started speaking + Simplified Chinese, contributed as a community translation through + [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). + It is still an early effort, so many parts of the app show in English until it + fills out — you can already pick it under Settings → Language or in Android's + per-app language settings. Thanks to + [zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it + started; help finishing it is very welcome. + +### Changed +- Long event titles wrap in the edit screen. When editing an event, a long title + now wraps onto multiple lines instead of being clipped to a single line, so you + can see and edit the whole thing ([#33]). ## [2.13.1] — 2026-07-06 @@ -843,8 +866,11 @@ automatically, with zero telemetry and no internet permission. [#19]: https://codeberg.org/jlmakiola/calendula/issues/19 [#20]: https://codeberg.org/jlmakiola/calendula/issues/20 [#24]: https://codeberg.org/jlmakiola/calendula/issues/24 +[#25]: https://codeberg.org/jlmakiola/calendula/issues/25 [#27]: https://codeberg.org/jlmakiola/calendula/issues/27 [#29]: https://codeberg.org/jlmakiola/calendula/issues/29 [#30]: https://codeberg.org/jlmakiola/calendula/issues/30 +[#32]: https://codeberg.org/jlmakiola/calendula/issues/32 +[#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37 diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 8171e19..0c7b98a 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -12,4 +12,5 @@ + diff --git a/fastlane/metadata/android/en-US/changelogs/21400.txt b/fastlane/metadata/android/en-US/changelogs/21400.txt index e6f8195..eac7a91 100644 --- a/fastlane/metadata/android/en-US/changelogs/21400.txt +++ b/fastlane/metadata/android/en-US/changelogs/21400.txt @@ -1,7 +1,30 @@ ### Added +- Restore events from a backup file. The backup section of Settings can now read + events back **in** from an `.ics` file, not just write one out: pick a file, + choose which calendar to import into, and Calendula adds the events — skipping + any that are already there and telling you how many it skipped. Export gained a + per-calendar selector at the same time, so you can back up just the calendars + you pick instead of everything at once ([#32]). +- Week numbers in Month view. A new **Week numbers** setting (off by default) + adds a slim gutter down the left of the Month grid showing the calendar-week + number for each row, sized to match the day cells. Handy if you plan or refer + to dates by week number ([#25]). - Tap a date header to open that day. In Week and Agenda view, tapping a date header now opens that date in Day view — the same drill-in that Month view and the agenda widget already offered, so every view behaves the same way. It makes jumping to a specific day quicker: switch to Week, swipe to the week you want, then tap the date to open it. Thanks to @ptab for the suggestion ([#37]). +- An early Simplified Chinese translation. Calendula has started speaking + Simplified Chinese, contributed as a community translation through + [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). + It is still an early effort, so many parts of the app show in English until it + fills out — you can already pick it under Settings → Language or in Android's + per-app language settings. Thanks to + [zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it + started; help finishing it is very welcome. + +### Changed +- Long event titles wrap in the edit screen. When editing an event, a long title + now wraps onto multiple lines instead of being clipped to a single line, so you + can see and edit the whole thing ([#33]).