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