From ca2069da0dc5934a19467bf4cea583a4acf16ec3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 21:25:28 +0200 Subject: [PATCH] Sort settings into the places you'd look for them, and say what each one does (#69) (#107) Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/107 --- .../calendula/domain/RecurrenceOccurrences.kt | 122 ++ .../calendula/ui/CalendarHost.kt | 36 +- .../calendula/ui/agenda/AgendaScreen.kt | 2 + .../calendula/ui/agenda/AgendaViewModel.kt | 10 +- .../calendula/ui/calendars/BackupScreen.kt | 390 ++++ .../calendula/ui/calendars/CalendarsScreen.kt | 388 +--- .../calendula/ui/common/Picker.kt | 43 +- .../calendula/ui/common/RecurrenceText.kt | 42 + .../calendula/ui/common/TimeZonePicker.kt | 8 +- .../calendula/ui/edit/EventEditScreen.kt | 48 +- .../ui/settings/AppearanceSettings.kt | 543 +++++ .../ui/settings/EventFormSettings.kt | 107 + .../ui/settings/NotificationSettings.kt | 410 ++++ .../calendula/ui/settings/PastEventsPicker.kt | 206 ++ .../calendula/ui/settings/SettingsCommon.kt | 156 ++ .../calendula/ui/settings/SettingsScreen.kt | 1949 ++--------------- .../ui/settings/SpecialDatesSettings.kt | 327 +++ .../calendula/ui/settings/ViewsSettings.kt | 428 ++++ .../calendula/ui/settings/WeekStartPicker.kt | 121 + .../calendula/ui/settings/WidgetSettings.kt | 160 ++ app/src/main/res/values/strings.xml | 63 +- .../domain/RecurrenceOccurrencesTest.kt | 178 ++ docs/ARCHITECTURE.md | 4 +- floret-kit | 2 +- 24 files changed, 3577 insertions(+), 2166 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrences.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrencesTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrences.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrences.kt new file mode 100644 index 0000000..0095c85 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrences.kt @@ -0,0 +1,122 @@ +package de.jeanlucmakiola.calendula.domain + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.minus +import kotlinx.datetime.number +import kotlinx.datetime.plus + +/** + * The first [limit] dates a [SimpleRecurrence] fires on, starting at [start] + * (the event's own start date, i.e. DTSTART), for showing a rule as dates + * instead of as words. + * + * A recurrence rule is the one place in the editor where a correct-sounding + * phrase can still mean something the user didn't intend — "every 2 weeks on + * Mon, Fri", "monthly" on the 31st — and the only honest answer is the dates + * themselves. This is a *preview*, deliberately kept to the shapes the picker + * can build; the provider remains the authority on what actually gets stored + * and expanded. + * + * The rules it mirrors, all from RFC 5545 (the spec the platform's recurrence + * engine implements): + * - a monthly or yearly rule **skips** a period the start day doesn't exist in, + * rather than sliding to the month's last day: the 31st recurring monthly + * lands only in 31-day months, 29 February yearly only in leap years. + * - a weekly rule with weekday picks repeats in blocks of `interval` weeks, + * with the week beginning on **Monday** — the RFC's default WKST, and the one + * that applies here because [toRRule] never writes a WKST part. The + * display-only "first day of week" preference deliberately doesn't enter into + * it, or the preview would disagree with the stored rule. + * - [RecurrenceEnd.Count] counts real occurrences (skipped periods don't + * consume one), [RecurrenceEnd.Until] is inclusive of its date. + * + * Returns fewer than [limit] dates when the series ends first, and an empty + * list when the rule yields nothing at all (an UNTIL before the start). + */ +fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List { + if (limit <= 0) return emptyList() + val until = (end as? RecurrenceEnd.Until)?.date + val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE + val wanted = minOf(limit, maxCount) + if (wanted <= 0) return emptyList() + + val result = mutableListOf() + var period = 0 + // Periods that produce nothing (a skipped 31st, a weekday block whose picks + // all fall before the start) must not stall the walk, so the cap is on + // periods examined rather than on dates found. + while (result.size < wanted && period < MAX_PERIODS) { + for (date in occurrencesInPeriod(period, start)) { + if (date < start) continue + if (until != null && date > until) return result + result += date + if (result.size == wanted) return result + } + period++ + } + return result +} + +/** The dates this rule's [period]-th repetition yields (empty when skipped). */ +private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List = + when (freq) { + RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY)) + RecurrenceFreq.Weekly -> weeklyOccurrences(period, start) + RecurrenceFreq.Monthly -> { + val month = start.plus(period * interval, DateTimeUnit.MONTH) + // plus() clamps the day into the shorter month; the rule instead + // skips such a period, so a clamped date means "not this month". + listOfNotNull(dateOrNull(month.year, month.month.number, start.day)) + } + RecurrenceFreq.Yearly -> + listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day)) + } + +/** + * One weekly block: every picked weekday inside the week that begins + * `period * interval` weeks after the start's own week, in weekday order. With + * no picks the rule simply repeats the start's weekday. + */ +private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List { + if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK)) + val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % + DAYS_PER_WEEK + val weekStart = start + .minus(daysIntoWeek, DateTimeUnit.DAY) + .plus(period * interval, DateTimeUnit.WEEK) + return byDays.sortedBy { it.isoDayNumber }.map { day -> + weekStart.plus( + (day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK, + DateTimeUnit.DAY, + ) + } +} + +/** + * Whether a run of [occurrences] starting at [start] leaves its starting year — + * i.e. whether showing them without a year would be ambiguous. + * + * Day and month alone are enough for a rule that stays inside one year, and + * dropping the year keeps the line short. They are actively misleading + * otherwise: a yearly rule reads as the same date three times over, and a + * monthly one rolling past December gives no hint that it has. + */ +fun occurrencesSpanYears(occurrences: List, start: LocalDate): Boolean = + occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1 + +/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */ +private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? = + runCatching { LocalDate(year, month, day) }.getOrNull() + +private const val DAYS_PER_WEEK = 7 + +/** + * How many repetitions to examine before giving up. Generous enough for the + * sparsest rule the picker can build (29 February yearly with a 999-year + * interval is nonsense; 31st monthly needs at most a handful), and bounded so a + * rule whose occurrences all fall outside its own UNTIL can't spin. + */ +private const val MAX_PERIODS = 2_000 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 4000f7c..53f006d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen +import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.calendula.ui.common.CalendarView @@ -148,6 +149,11 @@ fun CalendarHost( // over Settings and survives view switches. var showCalendars by rememberSaveable { mutableStateOf(false) } + // Backup & restore (#69) — like the manager, driven by the calendar list + // rather than by preferences, so it is hoisted here instead of living as a + // Settings sub-section. Reached from Settings and from the manager. + var showBackup by rememberSaveable { mutableStateOf(false) } + // Event form (v1.2 create) — same held-key pattern as the detail screen: // [heldCreateIso] keeps the prefill date alive through the slide-out. // [createStartMinutes] is the tapped slot's start (minutes from midnight) @@ -210,6 +216,7 @@ fun CalendarHost( fun dismissCoveringOverlays() { showSettings = false showCalendars = false + showBackup = false detailKey = null editKey = null importUri = null @@ -293,8 +300,8 @@ fun CalendarHost( // owns its own BackHandler and takes precedence). Disabled at the home view, // so back there falls through to the system and exits the app. val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null || - editKey != null || showSettings || showCalendars || importUri != null || - importForm != null + editKey != null || showSettings || showCalendars || showBackup || + importUri != null || importForm != null BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) { viewStack = viewStack.dropLast(1) } @@ -449,6 +456,7 @@ fun CalendarHost( SettingsScreen( onBack = { showSettings = false }, onManageCalendars = { showCalendars = true }, + onOpenBackup = { showBackup = true }, ) } @@ -489,12 +497,24 @@ fun CalendarHost( ) { CalendarsScreen( onBack = { showCalendars = false }, - // The manager opens the import too (restore from backup), and - // that way round it has to step aside: declared above the import - // overlays, it would otherwise cover the screen it just asked - // for. Closing it hands the user back to whatever opened the - // manager once the import is done. - onImport = { importUri = it; importForceMany = true; showCalendars = false }, + onOpenBackup = { showBackup = true }, + ) + } + + // Backup & restore — over the manager, since the manager links into it. + AnimatedVisibility( + visible = showBackup, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + BackupScreen( + onBack = { showBackup = false }, + // Restoring runs the normal .ics import, and that way round this + // screen has to step aside: declared above the import overlays, + // it would otherwise cover the screen it just asked for. Closing + // it hands the user back to whatever opened Backup once the + // import is done. + onImport = { importUri = it; importForceMany = true; showBackup = false }, ) } } 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 83006f0..2abaa99 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 @@ fun AgendaScreen( val anchor by viewModel.anchor.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle() + val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -198,6 +199,7 @@ fun AgendaScreen( title = stringResource(R.string.settings_agenda_range), description = stringResource(R.string.agenda_range_override_hint), selected = successState?.range ?: AgendaRange.Month, + weekStart = weekStart, onSelect = viewModel::setRangeOverride, onDismiss = { showRangePicker = false }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index af8c38b..e288003 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -48,8 +48,12 @@ class AgendaViewModel @Inject constructor( settingsPrefs.agendaShowRangeBar, ) { range, showBar -> AgendaSettings(range, showBar) } - // First day of the week, for the calendar-aligned "this week" range. - private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope) + /** + * First day of the week, for the calendar-aligned "this week" range. Public + * because the range picker resolves each option to real dates, which needs + * the same week start the window is built from. + */ + val weekStart: StateFlow = settingsPrefs.firstDayOfWeek(viewModelScope) /** * How to treat events that already ended today (show / dim / hide). A display @@ -90,7 +94,7 @@ class AgendaViewModel @Inject constructor( private val _rangeOverride = MutableStateFlow(null) val state: StateFlow = - combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart -> + combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart -> AgendaParams( anchor = anchor, range = override ?: settings.range, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt new file mode 100644 index 0000000..68eb813 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -0,0 +1,390 @@ +package de.jeanlucmakiola.calendula.ui.calendars + +import android.net.Uri +import android.text.format.DateUtils +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.BackupStatus +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.isEventTarget +import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.DialogAmountField +import de.jeanlucmakiola.floret.components.DialogUnitDropdown +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import java.time.LocalDate + +// 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", +) + +/** + * Backup & restore (#69). Local calendars aren't synced anywhere, so a `.ics` + * export is their only safety net — that made it a data-safety feature hiding in + * the calendar manager, where nobody went looking. It now has its own Settings + * entry, sharing [CalendarsViewModel] with the manager (both are driven by the + * same calendar list). + * + * A full-screen destination hoisted in `CalendarHost`; [onBack] pops it, + * [onImport] hands a picked file to the app's normal .ics import flow. + */ +@Composable +fun BackupScreen( + onBack: () -> Unit, + onImport: (Uri) -> Unit, + viewModel: CalendarsViewModel = hiltViewModel(), +) { + val calendars by viewModel.calendars.collectAsStateWithLifecycle() + val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() + val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() + + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + // Export covers the user's own local calendars (managed special-dates + // mirrors don't count — they are rebuilt from contacts). Restore can target + // any calendar the import picker would offer, so its availability is broader. + val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged } + val canImport = calendars.any { it.isEventTarget } + + // SAF "create document" target for the backup file. The picked Uri is handed + // 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 { viewModel.exportBackup(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. + val pickFolder = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> uri?.let(viewModel::setAutoBackupFolder) } + var showInterval by remember { mutableStateOf(false) } + + val backupFailedText = stringResource(R.string.calendars_backup_failed) + LaunchedEffect(backupResult) { + when (val r = backupResult) { + is BackupResult.Success -> { + snackbarHostState.showSnackbar( + context.resources.getQuantityString( + R.plurals.calendars_backup_done, r.eventCount, r.eventCount, + ), + ) + viewModel.consumeBackupResult() + } + BackupResult.Failure -> { + snackbarHostState.showSnackbar(backupFailedText) + viewModel.consumeBackupResult() + } + null -> Unit + } + } + + CollapsingScaffold( + title = stringResource(R.string.settings_section_backup), + onBack = onBack, + snackbarHost = { SnackbarHost(snackbarHostState) }, + predictiveBack = true, + ) { + HintText(stringResource(R.string.calendars_backup_hint)) + + if (exportable.isNotEmpty()) { + // One connected card: the one-time export on top, restore under it, + // then automatic backup (and its folder/interval rows when on). + GroupedRow( + title = stringResource(R.string.calendars_backup_action), + position = Position.Top, + leading = { LeadingAvatar(Icons.Default.FileDownload) }, + onClick = { + // 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( + title = stringResource(R.string.calendars_auto_backup), + summary = stringResource(R.string.calendars_auto_backup_hint), + position = if (autoBackup.enabled) Position.Middle else Position.Bottom, + leading = { LeadingAvatar(Icons.Default.Schedule) }, + trailing = { + Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled) + }, + onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) }, + ) + if (autoBackup.enabled) { + GroupedRow( + title = stringResource(R.string.calendars_auto_backup_folder), + summary = rememberFolderName(autoBackup.folderUri) + ?: stringResource(R.string.calendars_auto_backup_folder_unset), + position = Position.Middle, + onClick = { runCatching { pickFolder.launch(null) } }, + ) + GroupedRow( + title = stringResource(R.string.calendars_auto_backup_interval), + summary = backupIntervalLabel(autoBackup.intervalMinutes), + position = Position.Bottom, + onClick = { showInterval = true }, + ) + 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. + 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) } }, + ) + } + } + + if (showExportPicker) { + ExportCalendarPicker( + calendars = exportable, + onExport = viewModel::exportBackup, + onDismiss = { showExportPicker = false }, + ) + } + if (showInterval) { + BackupIntervalDialog( + currentMinutes = autoBackup.intervalMinutes, + onConfirm = viewModel::setAutoBackupIntervalMinutes, + onDismiss = { showInterval = 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: (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)) + } + } +} + +/** Readable name of the persisted backup folder, resolved from its tree Uri. */ +@Composable +private fun rememberFolderName(uriString: String?): String? { + val context = LocalContext.current + return remember(uriString) { + uriString?.let { + runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull() + } + } +} + +/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */ +@Composable +private fun backupIntervalLabel(minutes: Long): String { + val duration = when { + minutes % MINUTES_PER_WEEK == 0L -> + pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt()) + minutes % MINUTES_PER_DAY == 0L -> + pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt()) + minutes % 60L == 0L -> + pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt()) + else -> + pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt()) + } + return stringResource(R.string.calendars_auto_backup_every, duration) +} + +/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */ +@Composable +private fun backupStatusText(status: BackupStatus): String { + if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never) + val relative = DateUtils.getRelativeTimeSpanString( + status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, + ).toString() + return if (status.lastSuccess) { + stringResource(R.string.calendars_auto_backup_status_ok, relative) + } else { + stringResource(R.string.calendars_auto_backup_status_failed, relative) + } +} + +/** Amount + unit picker for the backup interval (floored at 30 minutes). */ +@Composable +private fun BackupIntervalDialog( + currentMinutes: Long, + onConfirm: (Long) -> Unit, + onDismiss: () -> Unit, +) { + // minutes-per-unit for each entry; pick the largest unit the current value divides into. + val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) } + val units = stringArrayResource(R.array.backup_interval_units).toList() + val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0) + var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) } + var unitIndex by rememberSaveable { mutableStateOf(initialUnit) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.calendars_auto_backup_interval)) }, + text = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1") + Spacer(Modifier.width(12.dp)) + DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it } + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.calendars_auto_backup_interval_min), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton(onClick = { + val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L + onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL)) + onDismiss() + }) { Text(stringResource(R.string.reminder_custom_set)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +private const val MINUTES_PER_DAY = 1_440L +private const val MINUTES_PER_WEEK = 10_080L 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 0caf61c..c9f3508 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 @@ -4,9 +4,6 @@ import android.accounts.AccountManager import android.content.Context import android.content.Intent import android.provider.Settings -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.background import androidx.compose.foundation.isSystemInDarkTheme @@ -26,26 +23,22 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.CalendarMonth 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.Info -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 -import androidx.compose.material.icons.filled.Schedule 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 @@ -67,34 +60,25 @@ 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 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource 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.net.toUri -import androidx.documentfile.provider.DocumentFile import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.data.prefs.BackupStatus -import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarStateLabel import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch -import de.jeanlucmakiola.calendula.domain.isEventTarget import de.jeanlucmakiola.calendula.domain.isNotSynced import de.jeanlucmakiola.calendula.domain.orderedForManager import de.jeanlucmakiola.calendula.domain.stateLabels @@ -109,50 +93,38 @@ import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage import de.jeanlucmakiola.floret.components.CollapsingScaffold -import de.jeanlucmakiola.floret.components.DialogAmountField -import de.jeanlucmakiola.floret.components.DialogUnitDropdown -import de.jeanlucmakiola.floret.components.FullScreenPicker -import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow +import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position -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), * and lists synced calendars read-only with a per-account "manage in the source - * app" deep-link — the app never touches a synced calendar's server. A - * full-screen destination; [onBack] pops it. + * app" deep-link — the app never touches a synced calendar's server. + * + * Export/import lives in its own Settings entry ([BackupScreen], #69); this + * screen only points at it, because the two are looked for separately: "which + * calendars do I have" versus "keep a copy of them". A full-screen destination; + * [onBack] pops it. */ @Composable fun CalendarsScreen( onBack: () -> Unit, - onImport: (android.net.Uri) -> Unit, + onOpenBackup: () -> Unit, viewModel: CalendarsViewModel = hiltViewModel(), ) { val calendars by viewModel.calendars.collectAsStateWithLifecycle() val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle() - val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() - val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() // null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar. // [editorSession] bumps on every open so the editor's field state resets for @@ -190,14 +162,7 @@ fun CalendarsScreen( synced = calendars.filterNot { it.isLocal }, error = error, onConsumeError = viewModel::consumeError, - backupResult = backupResult, - onExportBackup = viewModel::exportBackup, - onImport = onImport, - onConsumeBackupResult = viewModel::consumeBackupResult, - autoBackup = autoBackup, - onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled, - onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes, - onSetAutoBackupFolder = viewModel::setAutoBackupFolder, + onOpenBackup = onOpenBackup, onBack = onBack, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, @@ -213,14 +178,7 @@ private fun CalendarsList( synced: List, error: Boolean, onConsumeError: () -> Unit, - backupResult: BackupResult?, - onExportBackup: (android.net.Uri, Set?) -> Unit, - onImport: (android.net.Uri) -> Unit, - onConsumeBackupResult: () -> Unit, - autoBackup: AutoBackupUiState, - onSetAutoBackupEnabled: (Boolean) -> Unit, - onSetAutoBackupInterval: (Long) -> Unit, - onSetAutoBackupFolder: (android.net.Uri) -> Unit, + onOpenBackup: () -> Unit, onBack: () -> Unit, onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, @@ -242,47 +200,6 @@ private fun CalendarsList( } } - // SAF "create document" target for the backup file. The picked Uri is handed - // 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(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. - val pickFolder = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocumentTree(), - ) { uri -> uri?.let(onSetAutoBackupFolder) } - var showInterval by remember { mutableStateOf(false) } - - val backupFailedText = stringResource(R.string.calendars_backup_failed) - LaunchedEffect(backupResult) { - when (val r = backupResult) { - is BackupResult.Success -> { - snackbarHostState.showSnackbar( - context.resources.getQuantityString( - R.plurals.calendars_backup_done, r.eventCount, r.eventCount, - ), - ) - onConsumeBackupResult() - } - BackupResult.Failure -> { - snackbarHostState.showSnackbar(backupFailedText) - onConsumeBackupResult() - } - null -> Unit - } - } - CollapsingScaffold( title = stringResource(R.string.calendars_title), onBack = onBack, @@ -335,82 +252,24 @@ private fun CalendarsList( } } - // Backup — local calendars have no sync, so a .ics export is their only - // 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 calendar the import picker would offer - // (local or synced), so its availability is broader than export's. - val canImport = (local + synced).any { it.isEventTarget } - if (exportable.isNotEmpty()) { - Spacer(Modifier.height(16.dp)) - SectionHeader(stringResource(R.string.calendars_backup_header)) - HintText(stringResource(R.string.calendars_backup_hint)) - // One connected card: the one-time export on top, then automatic - // backup (and its folder/interval rows when on). - GroupedRow( - title = stringResource(R.string.calendars_backup_action), - position = Position.Top, - leading = { LeadingAvatar(Icons.Default.FileDownload) }, - onClick = { - // 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( - title = stringResource(R.string.calendars_auto_backup), - summary = stringResource(R.string.calendars_auto_backup_hint), - position = if (autoBackup.enabled) Position.Middle else Position.Bottom, - leading = { LeadingAvatar(Icons.Default.Schedule) }, - trailing = { - Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled) - }, - onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) }, - ) - if (autoBackup.enabled) { - GroupedRow( - title = stringResource(R.string.calendars_auto_backup_folder), - summary = rememberFolderName(autoBackup.folderUri) - ?: stringResource(R.string.calendars_auto_backup_folder_unset), - position = Position.Middle, - onClick = { runCatching { pickFolder.launch(null) } }, + // Backup lives in its own Settings entry now (#69) — this row is the + // pointer, so someone looking at their local calendars still finds the + // way to keep a copy of them. + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.settings_section_backup), + summary = stringResource(R.string.settings_backup_subtitle), + position = Position.Alone, + leading = { LeadingAvatar(Icons.Default.Backup) }, + trailing = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - GroupedRow( - title = stringResource(R.string.calendars_auto_backup_interval), - summary = backupIntervalLabel(autoBackup.intervalMinutes), - position = Position.Bottom, - onClick = { showInterval = true }, - ) - 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) } }, - ) - } + }, + onClick = onOpenBackup, + ) Spacer(Modifier.height(16.dp)) @@ -486,93 +345,6 @@ private fun CalendarsList( } } - if (showInterval) { - BackupIntervalDialog( - currentMinutes = autoBackup.intervalMinutes, - onConfirm = onSetAutoBackupInterval, - 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) @@ -958,113 +730,31 @@ private fun CalendarGroupMenu( @Composable -private fun SectionHeader(text: String) { +internal fun SectionHeader(text: String) { Text( text = text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + // The cards' own edge, so header and group share a left margin. + modifier = Modifier.padding( + start = GroupedListInset, + end = GroupedListInset, + top = 16.dp, + bottom = 4.dp, + ), ) } @Composable -private fun HintText(text: String) { +internal fun HintText(text: String) { Text( text = text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp), ) } -/** Readable name of the persisted backup folder, resolved from its tree Uri. */ -@Composable -private fun rememberFolderName(uriString: String?): String? { - val context = LocalContext.current - return remember(uriString) { - uriString?.let { - runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull() - } - } -} - -/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */ -@Composable -private fun backupIntervalLabel(minutes: Long): String { - val duration = when { - minutes % MINUTES_PER_WEEK == 0L -> - pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt()) - minutes % MINUTES_PER_DAY == 0L -> - pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt()) - minutes % 60L == 0L -> - pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt()) - else -> - pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt()) - } - return stringResource(R.string.calendars_auto_backup_every, duration) -} - -/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */ -@Composable -private fun backupStatusText(status: BackupStatus): String { - if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never) - val relative = DateUtils.getRelativeTimeSpanString( - status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, - ).toString() - return if (status.lastSuccess) { - stringResource(R.string.calendars_auto_backup_status_ok, relative) - } else { - stringResource(R.string.calendars_auto_backup_status_failed, relative) - } -} - -/** Amount + unit picker for the backup interval (floored at 30 minutes). */ -@Composable -private fun BackupIntervalDialog( - currentMinutes: Long, - onConfirm: (Long) -> Unit, - onDismiss: () -> Unit, -) { - // minutes-per-unit for each entry; pick the largest unit the current value divides into. - val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) } - val units = stringArrayResource(R.array.backup_interval_units).toList() - val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0) - var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) } - var unitIndex by rememberSaveable { mutableStateOf(initialUnit) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.calendars_auto_backup_interval)) }, - text = { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1") - Spacer(Modifier.width(12.dp)) - DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it } - } - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(R.string.calendars_auto_backup_interval_min), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - confirmButton = { - TextButton(onClick = { - val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L - onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL)) - onDismiss() - }) { Text(stringResource(R.string.reminder_custom_set)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } - }, - ) -} - -private const val MINUTES_PER_DAY = 1_440L -private const val MINUTES_PER_WEEK = 10_080L /** * Pick the app to open for managing a synced calendar's account. The account's diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index a5fcb2f..d0fe640 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -23,16 +23,26 @@ import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.floret.components.CustomAmountEditor import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter +import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary +import de.jeanlucmakiola.calendula.ui.agenda.dayCount +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock /** * Reminder-default picker, full-screen and **multi-select**: each [presets] @@ -48,6 +58,11 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange * row expands an inline number field plus a unit selector to add an arbitrary * lead time to the set. Changes apply live via [onSelect]; the user leaves via * back. + * + * [leadTimeSummary] adds a second line to each lead-time row. It exists for the + * all-day pickers, where the lead time alone doesn't say when anything happens: + * the hour comes from the separate "show all-day reminders at" setting, so + * "1 day before" is only half the answer until the row spells out the time. */ @Composable fun ReminderDefaultPicker( @@ -57,6 +72,7 @@ fun ReminderDefaultPicker( allowInherit: Boolean, onSelect: (ReminderOverride) -> Unit, onDismiss: () -> Unit, + leadTimeSummary: (@Composable (Int) -> String?)? = null, ) { // Optimistic local state: once the user edits, the chosen override is // authoritative while the picker is open, so quick successive toggles compose @@ -132,6 +148,7 @@ fun ReminderDefaultPicker( val checked = minute in selectedMinutes GroupedRow( title = reminderLeadTimeLabel(minute), + summary = leadTimeSummary?.invoke(minute), position = positionOf(index, rowCount), selected = checked, trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) }, @@ -196,14 +213,15 @@ private fun CustomReminderEditor( ) } -/** A short explanatory paragraph shown under a picker's title, above the rows. */ +/** A short explanatory paragraph shown under a picker's title, above the rows, + * on the same left margin as the rows themselves. */ @Composable internal fun PickerDescription(text: String) { Text( text = text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp), ) } @@ -213,12 +231,19 @@ internal fun PickerDescription(text: String) { * options (today / this week / this month) and the rolling windows (next 7 / 30 * days), with a "Custom" row that expands an inline day-count editor (1–365). * Mirrors [ReminderDefaultPicker]'s custom-expand pattern. + * + * Every option carries the dates it resolves to today as its summary, computed + * through the same [dayCount] the agenda windows by (hence [weekStart], which + * "This week" depends on). Without it the two groups are near-indistinguishable + * — "This week" and "Next 7 days" name different windows in the same words, and + * only the concrete span tells them apart. */ @Composable fun AgendaRangePicker( title: String, description: String, selected: AgendaRange, + weekStart: DayOfWeek, onSelect: (AgendaRange) -> Unit, onDismiss: () -> Unit, ) { @@ -232,10 +257,21 @@ fun AgendaRangePicker( mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "") } + val locale = currentLocale() + val zone = remember { TimeZone.currentSystemDefault() } + val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date } + // The window each option would open right now — the agenda's own end-day + // arithmetic (anchor + dayCount - 1), so the dates match what it will show. + val windowSummary: (AgendaRange) -> String = { range -> + val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY) + agendaRangeWindowSummary(range, today, end, locale) + } + val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position -> val isSelected = option == selected GroupedRow( title = agendaRangeLabel(option), + summary = windowSummary(option), position = position, selected = isSelected, trailing = if (isSelected) { @@ -270,6 +306,9 @@ fun AgendaRangePicker( } else { stringResource(R.string.agenda_range_custom) }, + // Only a chosen custom window has dates to show; an unset one has no + // day count yet, so the row stays single-line until it does. + summary = if (customSelected) windowSummary(selected) else null, position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount), selected = customSelected, trailing = if (customSelected) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt index 2ce12c9..fb3a582 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt @@ -8,6 +8,11 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.SimpleRecurrence +import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears +import de.jeanlucmakiola.calendula.domain.upcomingOccurrences +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import kotlinx.datetime.toJavaLocalDate import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime @@ -98,6 +103,43 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString { } } +/** + * The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for + * the recurrence picker. + * + * The phrase [recurrenceText] renders says what the rule *is*; this says what + * it *does*, which is the part a monthly rule on the 31st or an every-other-week + * rule with weekday picks gets wrong in people's heads. It expands the rule + * with [upcomingOccurrences], so it inherits that function's RFC reading — and + * its limits: only the shapes the picker itself can build. + * + * A rule that yields nothing at all (an end date before the start) says so + * rather than showing an empty list, since that is a mistake worth catching + * before saving. + */ +@Composable +fun nextOccurrencesText( + rule: SimpleRecurrence, + // Spelled out: in this file, the bare LocalDate is java.time's. + start: kotlinx.datetime.LocalDate, + locale: Locale, +): String { + val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT) + if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none) + // Years only once they carry information — a yearly rule is otherwise the + // same date repeated, and a monthly one crossing New Year hides that it did. + val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM" + val formatter = localizedDateFormatter(locale, pattern) + val formatted = dates.map { formatter.format(it.toJavaLocalDate()) } + return stringResource( + R.string.event_edit_recurrence_next, + ListFormatter.getInstance(locale).format(formatted), + ) +} + +/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */ +private const val NEXT_OCCURRENCE_COUNT = 3 + /** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */ private fun rruleDayName(token: String, locale: Locale): String? { val dow = when (token.takeLast(2).uppercase()) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index 6400212..fad9d51 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -39,6 +39,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptions import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position @@ -240,7 +241,12 @@ private fun SectionHeader(text: String) { text = text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + modifier = Modifier.padding( + start = GroupedListInset, + end = GroupedListInset, + top = 16.dp, + bottom = 4.dp, + ), ) } 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 f3c36bb..93105a4 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 @@ -157,6 +157,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel +import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText import de.jeanlucmakiola.calendula.ui.common.recurrenceText import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -1172,7 +1173,7 @@ private fun EventEditContent( if (showRecurrencePicker) { RecurrencePickerDialog( current = form.rrule, - startDay = form.start.date.dayOfWeek, + startDate = form.start.date, firstDayOfWeek = firstDayOfWeek, onSelect = { rrule -> viewModel.setRecurrence(rrule) @@ -1340,15 +1341,23 @@ private enum class RecurrenceEndMode { Never, Until, Count } * and an end condition — only that step needs an OK button. A rule the * simple shape can't express (ordinal BYDAY etc.) stays untouched unless the * user picks something here. + * + * Both steps show the rule as *dates* as well as words: every preset row + * carries the next few occurrences it would produce from [startDate], and the + * custom step repeats that under its live read-out. It is where a rule most + * easily means something other than it sounds like — "monthly" on the 31st + * skips February, an every-other-week rule lands a fortnight from the start's + * own week — and only the dates say so. */ @Composable private fun RecurrencePickerDialog( current: String?, - startDay: DayOfWeek, + startDate: LocalDate, firstDayOfWeek: DayOfWeek, onSelect: (String?) -> Unit, onDismiss: () -> Unit, ) { + val startDay = startDate.dayOfWeek val parsed = remember(current) { current?.let(::parseSimpleRecurrence) } val isPlainPreset = parsed != null && parsed.interval == 1 && parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty() @@ -1400,16 +1409,20 @@ private fun RecurrencePickerDialog( RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) } RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) } } - val customResult: String? = if (interval != null && customEnd != null) { + // Kept as the rule object, not just its RRULE text: the read-out renders the + // string, the date list expands the rule, and both must describe the one + // thing OK would save. + val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) { SimpleRecurrence( freq = freq, interval = interval, end = customEnd, byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(), - ).toRRule() + ) } else { null } + val customResult: String? = customRule?.toRRule() FullScreenPicker( title = stringResource(R.string.event_detail_recurrence), @@ -1440,6 +1453,7 @@ private fun RecurrencePickerDialog( RecurrenceFreq.entries.forEachIndexed { index, entry -> GroupedRow( title = stringResource(recurrencePresetLabel(entry)), + summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale), position = positionOf(index + 1, rowCount), selected = isPlainPreset && parsed?.freq == entry, trailing = if (isPlainPreset && parsed?.freq == entry) { @@ -1491,6 +1505,18 @@ private fun RecurrencePickerDialog( .padding(horizontal = 16.dp), ) + // The same rule as dates. Rendered even while the form is + // incomplete (as an empty line) so the controls below don't + // shift as it comes and goes. + Text( + text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) + // How often: an interval amount plus a frequency segmented row — // all four units on show, no unit hidden behind a dropdown. GroupedSurface( @@ -2007,6 +2033,11 @@ private fun readContactAddress(context: Context, uri: Uri): String? = /** * Visibility selector: one card per level, each with its own icon; the * current level is highlighted. Tap picks and closes. + * + * Every level carries a line saying who this affects, because the four words + * alone don't: visibility is about what *other people on a shared calendar* + * see, which is exactly the part the label leaves out — and "Confidential" has + * no meaning at all until you know the server decides what it does with it. */ @Composable private fun VisibilityPickerDialog( @@ -2019,6 +2050,7 @@ private fun VisibilityPickerDialog( options = AccessLevel.entries.toList(), selected = selected, label = { stringResource(accessLevelLabel(it)) }, + summary = { stringResource(accessLevelSummary(it)) }, onSelect = onSelect, onDismiss = onDismiss, leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) }, @@ -2111,6 +2143,14 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) { AccessLevel.Confidential -> R.string.event_access_confidential } +/** What each level means for people the calendar is shared with. */ +private fun accessLevelSummary(level: AccessLevel): Int = when (level) { + AccessLevel.Default -> R.string.event_access_default_summary + AccessLevel.Public -> R.string.event_access_public_summary + AccessLevel.Private -> R.string.event_access_private_summary + AccessLevel.Confidential -> R.string.event_access_confidential_summary +} + /** Humanise a reminder lead time, mirroring the detail screen's rendering. */ @Composable private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt new file mode 100644 index 0000000..37645e6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt @@ -0,0 +1,543 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +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.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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.appname.LauncherName +import de.jeanlucmakiola.calendula.data.prefs.ThemeMode +import de.jeanlucmakiola.calendula.domain.FontRole +import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.theme.BundledFont +import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN +import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN +import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf + +/** + * Appearance: how the app itself looks — theme and colour, the two typeface + * roles, and the launcher name. Anything that only changes how a *calendar view* + * reads (week start, time format, grid options) lives in [ViewsScreen] instead, + * and the widget-only settings in [WidgetsScreen] (#69). + */ +@Composable +internal fun AppearanceScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + var showTheme by remember { mutableStateOf(false) } + var showBrandFont by remember { mutableStateOf(false) } + var showPlainFont by remember { mutableStateOf(false) } + var showAppName by remember { mutableStateOf(false) } + + val fonts by viewModel.fontState.collectAsStateWithLifecycle() + val launcherName by viewModel.launcherName.collectAsStateWithLifecycle() + // A picked file that didn't parse as a font: tell the user and keep the old choice. + val context = LocalContext.current + val importFailedMessage = stringResource(R.string.settings_font_import_failed) + LaunchedEffect(Unit) { + viewModel.fontImportFailed.collect { + Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show() + } + } + + CollapsingScaffold( + title = stringResource(R.string.settings_section_appearance), + onBack = onBack, + predictiveBack = true, + ) { + // Theme & colour + GroupedRow( + title = stringResource(R.string.settings_theme), + summary = themeLabel(state.themeMode), + position = Position.Top, + onClick = { showTheme = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_dynamic_color), + // Says what it does when on; below Android 12 that is replaced by + // the reason the switch is dead. + summary = if (state.dynamicColorAvailable) { + stringResource(R.string.settings_dynamic_color_summary) + } else { + stringResource(R.string.settings_dynamic_color_unavailable) + }, + position = Position.Middle, + trailing = { + Switch( + checked = state.dynamicColor, + onCheckedChange = viewModel::setDynamicColor, + enabled = state.dynamicColorAvailable, + ) + }, + onClick = if (state.dynamicColorAvailable) { + { viewModel.setDynamicColor(!state.dynamicColor) } + } else { + null + }, + ) + GroupedRow( + title = stringResource(R.string.settings_soften_colors), + summary = stringResource(R.string.settings_soften_colors_summary), + position = Position.Bottom, + trailing = { + Switch( + checked = state.softenColors, + onCheckedChange = viewModel::setSoftenColors, + ) + }, + onClick = { viewModel.setSoftenColors(!state.softenColors) }, + ) + + Spacer(Modifier.height(16.dp)) + + // Fonts — the two Material typeface roles, each independently choosable + // (issue #19). Headings = brand (display/headline); body = plain + // (title/body/label). Both default to the system typeface. + GroupedRow( + title = stringResource(R.string.settings_font_headings), + summary = fontLabel(fonts.brand), + position = Position.Top, + onClick = { showBrandFont = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_font_body), + summary = fontLabel(fonts.plain), + position = Position.Bottom, + onClick = { showPlainFont = true }, + ) + + Spacer(Modifier.height(16.dp)) + + // App name — chooses the launcher label between "Calendula" and "Calendar" + // (issue #44). Own group: it's a launcher/system concern, not app styling. + // A sub-page chooser (not a switch), matching the app's other "choose one" + // settings and leaving room for more names later. + GroupedRow( + title = stringResource(R.string.settings_app_name), + summary = launcherNameLabel(launcherName), + position = Position.Alone, + onClick = { showAppName = true }, + ) + } + + if (showAppName) { + FullScreenPicker( + title = stringResource(R.string.settings_app_name), + onDismiss = { showAppName = false }, + predictiveBack = true, + ) { + // Show both names as launcher-mark previews so the user sees what + // they'd switch to, not just the current state. Tapping applies + // immediately and highlights — the picker stays open so the change is + // visible; back exits. + Text( + text = stringResource(R.string.settings_app_name_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(24.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + LauncherName.entries.forEach { option -> + AppNameOptionCard( + name = option, + selected = launcherName == option, + onClick = { viewModel.setLauncherName(option) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + if (showTheme) { + // No preview here on purpose: picking a theme repaints the app itself, + // which is a better demonstration than any thumbnail. What the list + // *can't* show is which way "Follow the system" currently falls, so that + // one option says so. + val systemDark = isSystemInDarkTheme() + OptionPicker( + title = stringResource(R.string.settings_theme), + header = { PickerDescription(stringResource(R.string.settings_theme_hint)) }, + predictiveBack = true, + options = ThemeMode.entries, + selected = state.themeMode, + label = { themeLabel(it) }, + summary = { mode -> + if (mode == ThemeMode.SYSTEM) { + stringResource( + R.string.settings_theme_system_summary, + themeLabel(if (systemDark) ThemeMode.DARK else ThemeMode.LIGHT), + ) + } else { + null + } + }, + onSelect = viewModel::setThemeMode, + onDismiss = { showTheme = false }, + ) + } + if (showBrandFont) { + FontPicker( + title = stringResource(R.string.settings_font_headings), + role = FontRole.BRAND, + selected = fonts.brand, + stamp = fonts.brandStamp, + onSelect = { viewModel.setFont(FontRole.BRAND, it) }, + onImport = { viewModel.importCustomFont(FontRole.BRAND, it) }, + onDismiss = { showBrandFont = false }, + ) + } + if (showPlainFont) { + FontPicker( + title = stringResource(R.string.settings_font_body), + role = FontRole.PLAIN, + selected = fonts.plain, + stamp = fonts.plainStamp, + onSelect = { viewModel.setFont(FontRole.PLAIN, it) }, + onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) }, + onDismiss = { showPlainFont = false }, + ) + } +} + +@Composable +private fun themeLabel(mode: ThemeMode): String = stringResource( + when (mode) { + ThemeMode.SYSTEM -> R.string.settings_theme_system + ThemeMode.LIGHT -> R.string.settings_theme_light + ThemeMode.DARK -> R.string.settings_theme_dark + }, +) + +/** The summary label for a stored font token (issue #19). */ +@Composable +private fun fontLabel(token: String): String = when (token) { + FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system) + FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected) + else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) } + ?: stringResource(R.string.settings_font_system) +} + +/** The display name for a launcher-label choice (issue #44). */ +@Composable +private fun launcherNameLabel(name: LauncherName): String = stringResource( + when (name) { + LauncherName.CALENDULA -> R.string.app_name + LauncherName.CALENDAR -> R.string.app_name_calendar_alias + }, +) + +/** + * MIME types offered to the document picker so it lists only font files. Covers + * the modern `font/` types plus the legacy `application/` font aliases some + * providers still report. Anything that slips through is still validated by + * [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use. + */ +private val FONT_PICKER_MIME_TYPES = arrayOf( + "font/ttf", + "font/otf", + "font/sfnt", + "font/collection", + "application/x-font-ttf", + "application/x-font-otf", + "application/font-sfnt", + "application/vnd.ms-opentype", +) + +/** + * Full-screen font chooser for one [FontRole]: the system default, each bundled + * font (previewed in its own face), and "Choose file…" which opens the system + * picker to load a .ttf/.otf. Selecting a system/bundled option applies at once; + * a file is validated and imported by the caller, switching to the custom font + * on success. + * + * Above the rows sits a specimen of the *current* choice, set in the type role + * this picker governs — headline for [FontRole.BRAND], body for + * [FontRole.PLAIN]. The per-row "Ag" says what a face looks like; only a full + * line at the real size says whether it works for the role, and it is the one + * thing that makes the two font settings distinguishable from each other. + * + * Being a preview picker, it stays open on selection (see `FullScreenPicker`): + * the specimen re-renders instead, which is the whole point of the screen. + */ +@Composable +private fun FontPicker( + title: String, + role: FontRole, + selected: String, + stamp: Int, + onSelect: (String) -> Unit, + onImport: (Uri) -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + // Stay open on a successful import too: the specimen switches to the + // imported face, which is the only look the user gets before committing. + if (uri != null) onImport(uri) + } + + // System default + the bundled fonts + the "Choose file…" row. + val rowCount = BundledFont.entries.size + 2 + val isCustom = selected == FONT_CUSTOM_TOKEN + // Resolving the custom face stats the disk and builds a fresh FontFamily, so + // memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file + // refreshes the preview while plain recompositions reuse the cached family. + val customPreview = remember(role, isCustom, stamp) { + if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null + } + // The face the specimen is set in: the same resolution the theme performs, + // so what is shown here is what the app will use. + val selectedFamily = remember(role, selected, stamp) { + resolveFontFamily(selected, role, context) + } + + FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) { + FontSpecimen(role = role, family = selectedFamily) + FontOptionRow( + label = stringResource(R.string.settings_font_system), + preview = FontFamily.Default, + selected = selected == FONT_SYSTEM_TOKEN, + position = positionOf(0, rowCount), + // Applies straight away and stays open — the specimen above is the + // answer to "what does this one look like". + onClick = { onSelect(FONT_SYSTEM_TOKEN) }, + ) + BundledFont.entries.forEachIndexed { index, font -> + FontOptionRow( + label = stringResource(font.labelRes), + preview = font.family, + selected = selected == font.token, + position = positionOf(index + 1, rowCount), + onClick = { onSelect(font.token) }, + ) + } + FontOptionRow( + label = if (isCustom) { + stringResource(R.string.settings_font_custom_selected) + } else { + stringResource(R.string.settings_font_choose_file) + }, + // A loaded font previews in its own face; otherwise show an upload cue. + preview = customPreview, + leadingIcon = if (isCustom) null else Icons.Default.UploadFile, + selected = isCustom, + position = positionOf(rowCount - 1, rowCount), + onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) }, + ) + } +} + +/** + * A specimen of [family] set in the type role [role] governs: a headline for + * the brand role, a paragraph for the plain one — the same styles the app draws + * with, only the family swapped, so nothing here can flatter a face the app + * won't reproduce. A null [family] is the system typeface, i.e. the Material + * default the styles already carry. + */ +@Composable +private fun FontSpecimen(role: FontRole, family: FontFamily?) { + val isBrand = role == FontRole.BRAND + val style = if (isBrand) { + MaterialTheme.typography.headlineMedium + } else { + MaterialTheme.typography.bodyLarge + } + Text( + text = stringResource( + if (isBrand) R.string.settings_font_specimen_heading else R.string.settings_font_specimen_body, + ), + style = style.copy(fontFamily = family ?: style.fontFamily), + color = MaterialTheme.colorScheme.onSurface, + // Two lines up front so switching between a wide and a narrow face + // doesn't shuffle the option list up and down under it. + minLines = 2, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = GroupedListInset, vertical = 8.dp), + ) +} + +/** + * One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in + * the option's own [preview] face (or an [leadingIcon] cue when there's nothing + * to preview), and a check when it's the current selection. + */ +@Composable +private fun FontOptionRow( + label: String, + preview: FontFamily?, + selected: Boolean, + position: Position, + onClick: () -> Unit, + leadingIcon: ImageVector? = null, +) { + GroupedRow( + title = label, + position = position, + selected = selected, + leading = { + if (preview != null) { + Text( + text = "Ag", + fontFamily = preview, + style = MaterialTheme.typography.titleLarge, + ) + } else if (leadingIcon != null) { + Icon(imageVector = leadingIcon, contentDescription = null) + } + }, + trailing = if (selected) { + { SelectedCheck() } + } else { + null + }, + onClick = onClick, + ) +} + +/** + * One selectable launcher-name preview in the App name picker (issue #44): the + * app's launcher mark over the name, framed as a card. The active one carries a + * primary border, a tinted container and a check; tapping selects it. The mark + * is the same for both — only the label changes — so the card previews exactly + * what the home screen will read. + */ +@Composable +private fun AppNameOptionCard( + name: LauncherName, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(24.dp) + val borderColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + } + val containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + Column( + modifier = modifier + .clip(shape) + .background(containerColor) + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape) + .clickable(onClick = onClick) + .padding(vertical = 20.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // The adaptive launcher mark, reconstructed as a squircle (as in the + // onboarding BrandHero) so it renders identically everywhere. + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(18.dp)) + .background(colorResource(R.color.ic_launcher_background)), + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + } + Text( + text = launcherNameLabel(name), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + maxLines = 1, + ) + // Selection indicator: a filled check when active, an empty ring otherwise. + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) + .then( + if (selected) { + Modifier + } else { + Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) + }, + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(16.dp), + ) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt new file mode 100644 index 0000000..9f6c8a3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt @@ -0,0 +1,107 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon +import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf + +/** New event form: which fields it opens with, and how it behaves. */ +@Composable +internal fun EventFormScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_event_form), + onBack = onBack, + predictiveBack = true, + ) { + SettingsHint(stringResource(R.string.settings_form_fields_hint)) + Spacer(Modifier.height(8.dp)) + val fields = EventFormField.entries + fields.forEachIndexed { index, field -> + val checked = field in state.defaultFormFields + GroupedRow( + title = stringResource(eventFormFieldLabel(field)), + position = positionOf(index, fields.size), + // Same icon the field carries in the new-event form, so a toggle + // is easy to match to the field it controls. + leading = { + Icon( + imageVector = eventFormFieldIcon(field), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = { + Switch( + checked = checked, + onCheckedChange = { viewModel.setFormFieldDefault(field, it) }, + ) + }, + onClick = { viewModel.setFormFieldDefault(field, !checked) }, + ) + } + + // Auto-focus the title on a new event (issue #10) — on by default, since + // most events get a title; raising the keyboard saves a tap. Off lets you + // set the time/calendar first without the keyboard in the way. + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_autofocus_title), + summary = stringResource(R.string.settings_autofocus_title_hint), + position = Position.Alone, + leading = { + Icon( + imageVector = Icons.Default.Keyboard, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = { + Switch( + checked = state.autofocusEventTitle, + onCheckedChange = { viewModel.setAutofocusEventTitle(it) }, + ) + }, + onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) }, + ) + + // Per-event colour on calendars that publish no colour set (some + // CalDAV) — off by default, with the honest caveat that the colour may + // not survive their next sync. Local and palette calendars ignore it. + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_color_unsupported), + summary = stringResource(R.string.settings_color_unsupported_hint), + position = Position.Alone, + trailing = { + Switch( + checked = state.allowColorOnUnsupportedCalendars, + onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) }, + ) + }, + onClick = { + viewModel.setAllowColorOnUnsupportedCalendars( + !state.allowColorOnUnsupportedCalendars, + ) + }, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt new file mode 100644 index 0000000..2242e41 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -0,0 +1,410 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS +import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker +import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker +import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.identity.collapseExit +import de.jeanlucmakiola.floret.identity.expandEnter +import de.jeanlucmakiola.floret.reminders.ReminderOverride +import de.jeanlucmakiola.floret.reminders.reminderOverrideFor +import kotlinx.datetime.LocalTime + +/** + * Reminder-notifications toggle (v1.4), mirroring the onboarding step. + * Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) — + * the pref is set either way; the OS permission is the real gate. + */ +@Composable +internal fun NotificationsScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, + onOpenSpecialDates: () -> Unit, +) { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { /* The pref is already on; a denial just leaves the OS gate shut. */ } + val toggleReminders: (Boolean) -> Unit = { enabled -> + viewModel.setRemindersEnabled(enabled) + val needsPermission = enabled && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + if (needsPermission) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + var showDefaultReminder by remember { mutableStateOf(false) } + var showAllDayReminder by remember { mutableStateOf(false) } + var showAllDayReminderTime by remember { mutableStateOf(false) } + var showSnooze by remember { mutableStateOf(false) } + var overrideDialog by remember { mutableStateOf(null) } + var calendarSectionExpanded by remember { mutableStateOf(false) } + var expandedCalendars by remember { mutableStateOf(emptySet()) } + + CollapsingScaffold( + title = stringResource(R.string.settings_section_notifications), + onBack = onBack, + predictiveBack = true, + ) { + GroupedRow( + title = stringResource(R.string.settings_reminders), + summary = stringResource(R.string.settings_reminders_hint), + position = Position.Top, + trailing = { + Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders) + }, + onClick = { toggleReminders(!state.remindersEnabled) }, + ) + GroupedRow( + title = stringResource(R.string.settings_default_reminder), + summary = reminderChoiceLabel(state.defaultReminderMinutes), + position = Position.Middle, + onClick = { showDefaultReminder = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_default_reminder_allday), + summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes), + position = Position.Middle, + onClick = { showAllDayReminder = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_allday_reminder_time), + summary = stringResource( + R.string.settings_allday_reminder_time_hint, + settingsTimeOfDay(state.allDayReminderTimeMinutes), + ), + position = Position.Bottom, + onClick = { showAllDayReminderTime = true }, + ) + + // Delivery reliability + snooze: both are global reminder-delivery + // settings, so they sit with the defaults above rather than below the + // long per-calendar list. Reliability is a soft, optional battery- + // optimisation exemption (system-settings deep-link, no special + // permission); shown as live status, reversible at any time. + Spacer(Modifier.height(24.dp)) + val batteryExempt = rememberBatteryOptimizationExempt() + GroupedRow( + title = stringResource(R.string.settings_reliable_delivery), + summary = if (batteryExempt) { + stringResource(R.string.settings_reliable_delivery_exempt) + } else { + stringResource(R.string.settings_reliable_delivery_hint) + }, + position = Position.Top, + trailing = if (batteryExempt) { + { SelectedCheck() } + } else { + null + }, + onClick = { openBatteryOptimizationSettings(context) }, + ) + + // Snooze: how long the notification's "Snooze" action defers a reminder. + GroupedRow( + title = stringResource(R.string.settings_snooze_duration), + summary = snoozeDurationLabel(state.snoozeMinutes), + position = Position.Bottom, + onClick = { showSnooze = true }, + ) + + // Per-calendar overrides: the whole section folds behind one header to + // keep the screen tidy. Expanded, each writable calendar gets its own + // expandable card that may keep, drop, or replace the global default — + // separately for timed and all-day events. + if (state.writableCalendars.isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_calendar_reminders_title), + summary = stringResource(R.string.settings_calendar_reminders_hint), + position = Position.Alone, + trailing = { + Icon( + imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { calendarSectionExpanded = !calendarSectionExpanded }, + ) + AnimatedVisibility( + visible = calendarSectionExpanded, + enter = expandEnter(), + exit = collapseExit(), + ) { + Column { + state.writableCalendars.forEach { calendar -> + Spacer(Modifier.height(16.dp)) + // A contact special-dates calendar owns its reminders in + // its own section — link there instead of an override. + if (calendar.id in state.managedCalendarIds) { + GroupedRow( + title = calendar.displayName, + summary = stringResource(R.string.settings_calendar_reminders_managed_hint), + position = Position.Alone, + leading = { CalendarColorChip(calendar.color) }, + trailing = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = onOpenSpecialDates, + ) + return@forEach + } + val expanded = calendar.id in expandedCalendars + // Calendar card; tapping expands it into a grouped list + // of three (the card + the timed and all-day rows). + GroupedRow( + title = calendar.displayName, + position = if (expanded) Position.Top else Position.Alone, + leading = { CalendarColorChip(calendar.color) }, + trailing = { + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { + expandedCalendars = if (expanded) { + expandedCalendars - calendar.id + } else { + expandedCalendars + calendar.id + } + }, + ) + AnimatedVisibility( + visible = expanded, + enter = expandEnter(), + exit = collapseExit(), + ) { + Column { + val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id) + GroupedRow( + title = stringResource(R.string.settings_default_reminder), + summary = calendarOverrideSummary(timed, state.defaultReminderMinutes), + position = Position.Middle, + onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) }, + ) + val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id) + GroupedRow( + title = stringResource(R.string.settings_default_reminder_allday), + summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes), + position = Position.Bottom, + onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) }, + ) + } + } + } + } + } + } + } + + if (showSnooze) { + SnoozeDurationPicker( + title = stringResource(R.string.settings_snooze_duration), + presets = SNOOZE_PRESETS, + selected = state.snoozeMinutes, + label = { snoozeDurationLabel(it) }, + onSelect = { viewModel.setSnoozeMinutes(it) }, + onDismiss = { showSnooze = false }, + ) + } + if (showDefaultReminder) { + ReminderDefaultPicker( + title = stringResource(R.string.settings_default_reminder), + presets = REMINDER_PRESETS, + selected = state.defaultReminderMinutes.toReminderChoice(), + allowInherit = false, + onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) }, + onDismiss = { showDefaultReminder = false }, + ) + } + if (showAllDayReminder) { + ReminderDefaultPicker( + title = stringResource(R.string.settings_default_reminder_allday), + presets = ALLDAY_REMINDER_PRESETS, + selected = state.defaultAllDayReminderMinutes.toReminderChoice(), + allowInherit = false, + onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) }, + onDismiss = { showAllDayReminder = false }, + leadTimeSummary = allDayFiringTimeSummary(state.allDayReminderTimeMinutes), + ) + } + if (showAllDayReminderTime) { + TimePickerAlert( + initial = LocalTime( + state.allDayReminderTimeMinutes / 60, + state.allDayReminderTimeMinutes % 60, + ), + onConfirm = { + viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute) + showAllDayReminderTime = false + }, + onDismiss = { showAllDayReminderTime = false }, + ) + } + overrideDialog?.let { target -> + val map = if (target.isAllDay) { + state.perCalendarAllDayReminderOverride + } else { + state.perCalendarReminderOverride + } + ReminderDefaultPicker( + title = stringResource( + if (target.isAllDay) { + R.string.settings_default_reminder_allday + } else { + R.string.settings_default_reminder + }, + ), + presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS, + selected = map.reminderOverrideFor(target.calendarId), + allowInherit = true, + onSelect = { + if (target.isAllDay) { + viewModel.setCalendarAllDayReminderOverride(target.calendarId, it) + } else { + viewModel.setCalendarReminderOverride(target.calendarId, it) + } + }, + onDismiss = { overrideDialog = null }, + leadTimeSummary = if (target.isAllDay) { + allDayFiringTimeSummary(state.allDayReminderTimeMinutes) + } else { + null + }, + ) + } +} + +/** Which calendar + event kind a per-calendar reminder-override dialog targets. */ +private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean) + +/** A global default (empty = none) as a picker choice for selection highlighting. */ +private fun List.toReminderChoice(): ReminderOverride = + if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this) + +/** A picked choice as global-default minutes (Inherit isn't offered for globals). */ +private fun ReminderOverride.toMinutesList(): List = + (this as? ReminderOverride.Minutes)?.minutes ?: emptyList() + +/** Row summary for a calendar: its override, or the inherited global default. */ +@Composable +private fun calendarOverrideSummary( + choice: ReminderOverride, + globalDefault: List, +): String = when (choice) { + ReminderOverride.Inherit -> + stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault)) + ReminderOverride.None -> stringResource(R.string.reminder_none) + is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes) +} + +/** Snooze delays offered for the notification "Snooze" action, in minutes. */ +private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60) + +/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */ +@Composable +private fun snoozeDurationLabel(minutes: Int): String = + if (minutes % 60 == 0) { + pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60) + } else { + pluralStringResource(R.plurals.duration_minutes, minutes, minutes) + } + +/** + * Whether Calendula is exempt from battery optimisation, re-read on every + * `ON_RESUME` so the row reflects a change the user just made in system + * settings without needing to leave and re-enter the screen. + */ +@Composable +private fun rememberBatteryOptimizationExempt(): Boolean { + val context = LocalContext.current + var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + exempt = isIgnoringBatteryOptimizations(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + return exempt +} + +private fun isIgnoringBatteryOptimizations(context: Context): Boolean { + val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return power.isIgnoringBatteryOptimizations(context.packageName) +} + +/** + * Take the user straight to Calendula's exemption: the direct + * `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore + * battery optimisation?") rather than the full app list they'd have to scroll. + * Falls back to the optimisation list if the OS refuses the direct intent. + */ +private fun openBatteryOptimizationSettings(context: Context) { + val direct = Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:${context.packageName}".toUri(), + ) + if (runCatching { context.startActivity(direct) }.isFailure) { + runCatching { + context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt new file mode 100644 index 0000000..049ed11 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt @@ -0,0 +1,206 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.clipToBounds +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader +import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow +import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock + +/** + * The "past events" chooser for the Agenda (#69 follow-up). + * + * "Show / dim / hide" is the case where the word cannot carry the choice — + * *how far* does dimmed fade, and what is left behind when finished events are + * hidden? So a stand-in agenda day sits above the options and re-renders as you + * pick one, using the very rows the Agenda draws: dimming is + * [AgendaEventRow]'s own `dimmed` flag, and hiding drops the row exactly as + * [de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen] filters it — so the + * preview cannot claim an effect the screen doesn't have. + * + * Like the Month style and App name pickers, selecting applies immediately and + * leaves the picker open: closing would hide the thing the screen is for. Back + * exits. + */ +@Composable +internal fun PastEventsPicker( + selected: PastEventDisplay, + onSelect: (PastEventDisplay) -> Unit, + onDismiss: () -> Unit, +) { + val options = PastEventDisplay.entries + val reduceMotion = rememberReduceMotion() + FullScreenPicker( + title = stringResource(R.string.settings_past_events), + onDismiss = onDismiss, + predictiveBack = true, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .height(PREVIEW_HEIGHT), + contentAlignment = Alignment.TopCenter, + ) { + Crossfade( + targetState = selected, + animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), + label = "past-events-preview", + ) { shown -> + Box( + modifier = Modifier + .clip(PREVIEW_SHAPE) + .background(MaterialTheme.colorScheme.surface) + .clipToBounds(), + ) { + PastEventsPreview(mode = shown) + } + } + } + PickerDescription(stringResource(R.string.settings_past_events_hint)) + options.forEachIndexed { index, mode -> + val isSelected = mode == selected + GroupedRow( + title = stringResource(pastEventDisplayLabelRes(mode)), + position = positionOf(index, options.size), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + // Applies straight away; the preview above is the confirmation. + onClick = { onSelect(mode) }, + ) + } + } +} + +/** + * A stand-in agenda day in a given [mode]: three events on today, the first two + * already finished. The times are fixed rather than derived from the clock, so + * the preview reads the same at any hour — what varies is only what [mode] does + * to the two finished rows. + */ +@Composable +private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modifier) { + val zone = remember { TimeZone.currentSystemDefault() } + val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date } + val titles = listOf( + stringResource(R.string.settings_past_events_sample_morning), + stringResource(R.string.settings_past_events_sample_midday), + stringResource(R.string.settings_past_events_sample_evening), + ) + val sample = remember(today, zone, titles) { samplePastDay(today, zone, titles) } + // Hiding drops the finished rows outright; showing and dimming keep them, + // and only dimming fades them — the same three-way split AgendaContent makes. + val visible = if (mode == PastEventDisplay.HIDE) sample.filterNot { it.hasPassed } else sample + + Column( + modifier = modifier + .fillMaxWidth() + .clipToBounds() + // A preview is a picture, not a control: swallow touches before the + // rows' own clickables see them, and keep it out of the reading order — + // the options below already say what is selected. + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() } + } + } + } + .clearAndSetSemantics { }, + ) { + AgendaDayHeader(date = today, today = today, onOpenDay = {}) + visible.forEachIndexed { index, row -> + AgendaEventRow( + event = row.event, + day = today, + zone = zone, + position = positionOf(index, visible.size), + dimmed = mode == PastEventDisplay.DIM && row.hasPassed, + onClick = {}, + ) + } + } +} + +/** One stand-in row plus whether it counts as finished. */ +private class SampleRow(val event: EventInstance, val hasPassed: Boolean) + +/** + * Today's stand-in events, [titles] in order: two finished (morning, midday) and + * one still to come. + * + * Colours are raw ARGB on purpose — that is what the provider hands out for an + * event, so a theme token here would misrepresent what the agenda renders. + */ +private fun samplePastDay(today: LocalDate, zone: TimeZone, titles: List): List = + listOf( + sampleRow(1, titles[0], today, zone, 9, 10, 0xFF3F7BD4.toInt(), hasPassed = true), + sampleRow(2, titles[1], today, zone, 12, 13, 0xFFCE5B4C.toInt(), hasPassed = true), + sampleRow(3, titles[2], today, zone, 18, 19, 0xFF4E9A6A.toInt(), hasPassed = false), + ) + +private fun sampleRow( + id: Long, + title: String, + day: LocalDate, + zone: TimeZone, + startHour: Int, + endHour: Int, + color: Int, + hasPassed: Boolean, +): SampleRow = SampleRow( + event = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = day.atTime(startHour, 0).toInstant(zone), + end = day.atTime(endHour, 0).toInstant(zone), + isAllDay = false, + color = color, + location = null, + ), + hasPassed = hasPassed, +) + +/** Room for the day header plus its three two-line rows, so hiding shortens the + * list inside a box that keeps its height instead of the page jumping a row. */ +private val PREVIEW_HEIGHT = 288.dp +private val PREVIEW_SHAPE = RoundedCornerShape(12.dp) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt new file mode 100644 index 0000000..cb36a77 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt @@ -0,0 +1,156 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay +import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.locale.currentLocale + +/** + * Pieces shared by the settings hub and its sub-screens (`*Settings.kt`). Each + * sub-screen owns whatever only it uses; anything two of them need lives here. + */ + +/** + * Token-based accent for a leading icon chip (container / on-container pair). + * Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt + * to theme, dark mode and dynamic colour. + * + * The chips are a scanning aid, so the rule is **no two rows in one group share + * an accent** — a group in a single colour carries no more information than no + * colour at all. [Neutral] is not a fourth colour to rotate through but a + * deliberate step back, for rows that are reference or last-resort rather than + * somewhere you routinely go. + */ +internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary } + +/** + * Leading circular icon chip. Colours come from the M3 scheme via a container / + * on-container token pair, so each accent stays correctly paired across theme, + * dark mode and dynamic colour. + */ +@Composable +internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) { + val scheme = MaterialTheme.colorScheme + val (background, iconColor) = when (accent) { + ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant + ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer + ChipAccent.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer + ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer + } + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(background), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(22.dp), + ) + } +} + +/** + * A small primary-coloured group label, matching the Calendars settings screen. + * + * Starts on [GroupedListInset] — the cards' own edge — so a header, the hint + * under it and the group it names all share one left margin. + */ +@Composable +internal fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding( + start = GroupedListInset, + end = GroupedListInset, + top = 16.dp, + bottom = 4.dp, + ), + ) +} + +/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */ +@Composable +internal fun SettingsHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp), + ) +} + +internal fun openUrl(context: Context, url: String) { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + runCatching { context.startActivity(intent) } +} + +/** Label for a global-default choice: empty → "None", else the lead times joined. */ +@Composable +internal fun reminderChoiceLabel(minutes: List): String { + if (minutes.isEmpty()) return stringResource(R.string.reminder_none) + return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ") +} + +/** + * Lead times offered for the all-day default — day-scale, since a "minutes + * before midnight" reminder on an all-day event is rarely what's wanted. Shared + * with the contact special-dates calendars, whose events are all all-day. + */ +internal val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080) + +/** + * A minute-of-day ("09:00") in the app's *own* 12/24-hour convention — the same + * [LocalUse24HourFormat] the agenda and event rows read, so a time shown in + * settings can't disagree with the times shown everywhere else. + */ +@Composable +internal fun settingsTimeOfDay(minutesOfDay: Int): String = + formatMinuteOfDay(minutesOfDay, LocalUse24HourFormat.current, currentLocale()) + +/** + * Second line for an all-day lead-time row: the clock time it fires at. + * + * An all-day occurrence has no time of its own, so a reminder's stored offset + * only decides *which day* it belongs to — the hour always comes from the + * global "show all-day reminders at" setting (see + * [de.jeanlucmakiola.calendula.domain.reminders.planReminders]). Naming it on + * every row is what makes "At time of event" readable on an all-day default at + * all: it means that day at 09:00, not midnight. + * + * Shared by the notification defaults, the per-calendar overrides and the + * contact special-dates calendars — all three read the same one setting. + */ +@Composable +internal fun allDayFiringTimeSummary(allDayReminderTimeMinutes: Int): @Composable (Int) -> String? { + val summary = stringResource( + R.string.settings_allday_reminder_fires_at, + settingsTimeOfDay(allDayReminderTimeMinutes), + ) + return { summary } +} 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 2ef5434..f28dbff 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 @@ -1,21 +1,5 @@ package de.jeanlucmakiola.calendula.ui.settings -import android.Manifest -import android.app.StatusBarManager -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.drawable.Icon -import android.net.Uri -import android.os.Build -import android.os.PowerManager -import android.provider.Settings -import android.widget.Toast -import androidx.annotation.RequiresApi -import android.text.format.DateFormat -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -23,12 +7,7 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -36,40 +15,25 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Cake import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Dashboard -import androidx.compose.material.icons.filled.DragHandle -import androidx.compose.material.icons.filled.SwapVert -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Gavel -import androidx.compose.material.icons.filled.Keyboard import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PrivacyTip +import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Tune -import androidx.compose.material.icons.filled.UploadFile -import androidx.compose.material3.FilledTonalButton -import de.jeanlucmakiola.floret.locale.AppLanguage -import androidx.compose.material3.Icon +import androidx.compose.material.icons.filled.Widgets import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -78,106 +42,49 @@ 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.data.appname.LauncherName -import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission -import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay -import de.jeanlucmakiola.floret.reminders.ReminderOverride -import de.jeanlucmakiola.floret.reminders.reminderOverrideFor -import de.jeanlucmakiola.calendula.data.prefs.ThemeMode -import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref -import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref -import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay -import de.jeanlucmakiola.calendula.domain.EventFormField -import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType -import de.jeanlucmakiola.calendula.qs.NewEventTileService +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec +import de.jeanlucmakiola.floret.components.AboutCard +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.openIssueTracker import de.jeanlucmakiola.floret.crash.submitCrashReport -import de.jeanlucmakiola.calendula.domain.FontRole -import de.jeanlucmakiola.floret.identity.collapseExit -import de.jeanlucmakiola.floret.identity.expandEnter -import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker -import de.jeanlucmakiola.calendula.ui.common.PickerDescription -import de.jeanlucmakiola.floret.components.FullScreenPicker -import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel -import de.jeanlucmakiola.calendula.ui.common.CalendarView -import de.jeanlucmakiola.floret.components.AboutCard -import de.jeanlucmakiola.floret.components.AboutLink -import de.jeanlucmakiola.floret.components.CollapsingScaffold -import de.jeanlucmakiola.floret.components.GroupedRow -import de.jeanlucmakiola.floret.components.InlineTextField -import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig -import de.jeanlucmakiola.calendula.ui.month.labelRes -import de.jeanlucmakiola.floret.components.ReorderableColumn -import de.jeanlucmakiola.floret.components.ReorderableRowHeight -import de.jeanlucmakiola.calendula.ui.common.icon -import de.jeanlucmakiola.calendula.ui.common.labelRes -import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip -import de.jeanlucmakiola.floret.components.OptionPicker -import de.jeanlucmakiola.floret.components.Position -import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS -import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker -import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert -import de.jeanlucmakiola.floret.components.SelectedCheck -import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel -import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec -import de.jeanlucmakiola.floret.locale.currentLocale -import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon -import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel -import de.jeanlucmakiola.calendula.ui.theme.BundledFont -import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN -import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN -import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily -import de.jeanlucmakiola.calendula.widget.WidgetSize -import kotlinx.datetime.DayOfWeek -import kotlinx.datetime.LocalTime -import java.time.format.TextStyle as JavaTextStyle -import java.util.Calendar +import de.jeanlucmakiola.floret.locale.AppLanguage /** The settings sub-screens reached from the hub's category rows. */ -private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates } +private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates, Widgets } /** - * Token-based accent for a leading icon chip (container / on-container pair). - * Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt - * to theme, dark mode and dynamic colour. - */ -private enum class ChipAccent { Neutral, Primary, Tertiary } - -/** - * Settings (M4), restructured in v2.3 into a category hub with sub-screens. - * Both the hub and the sub-screens use a collapsing [LargeTopAppBar] and the - * grouped-row card system. Calendars opens the separate manager hoisted in - * [CalendarHost]; Language opens a full-screen picker; About is a card - * at the top. A full-screen destination; [onBack] pops it. + * Settings (M4), restructured in v2.3 into a category hub with sub-screens and + * re-sorted in v2.17 (#69) so every setting sits where it is looked for. + * + * The hub reads as three labelled groups — what the app looks like and how it + * behaves, what it does with your data, and the app itself — and each sub-screen + * lives in its own `*Settings.kt` file. Calendars and Backup open screens + * hoisted in `CalendarHost` (they are driven by the calendar list, not by + * preferences); Language opens a full-screen picker; About is a card at the top. + * A full-screen destination; [onBack] pops it. */ @Composable fun SettingsScreen( onBack: () -> Unit, onManageCalendars: () -> Unit, + onOpenBackup: () -> Unit, modifier: Modifier = Modifier, viewModel: SettingsViewModel = hiltViewModel(), ) { @@ -194,6 +101,7 @@ fun SettingsScreen( onBack = onBack, onOpenSection = { section = it }, onManageCalendars = onManageCalendars, + onOpenBackup = onOpenBackup, ) AnimatedVisibility( @@ -236,6 +144,13 @@ fun SettingsScreen( ) { SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }) } + AnimatedVisibility( + visible = section == SettingsSection.Widgets, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }) + } } } @@ -248,23 +163,34 @@ private fun SettingsHub( onBack: () -> Unit, onOpenSection: (SettingsSection) -> Unit, onManageCalendars: () -> Unit, + onOpenBackup: () -> Unit, ) { CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) { - Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() } + // The card and the support row are one grouped block: the call to action + // is a row continuing the container rather than a tonal button sitting + // inside the card. + Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() } + SupportRow() Spacer(Modifier.height(16.dp)) + // Three labelled groups instead of one long undifferentiated list (#69): + // how the app presents itself, what it does with your data, and the app + // as an installed thing. Each group cycles its chip accents so no two + // rows in it look alike (see [ChipAccent]) — a uniformly coloured group + // is as unscannable as an uncoloured one. + SectionHeader(stringResource(R.string.settings_group_look)) GroupedRow( title = stringResource(R.string.settings_section_appearance), summary = stringResource(R.string.settings_appearance_subtitle), position = Position.Top, - leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) }, + leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Secondary) }, onClick = { onOpenSection(SettingsSection.Appearance) }, ) GroupedRow( title = stringResource(R.string.settings_section_views), summary = stringResource(R.string.settings_views_subtitle), position = Position.Middle, - leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) }, + leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Tertiary) }, onClick = { onOpenSection(SettingsSection.Views) }, ) GroupedRow( @@ -277,14 +203,17 @@ private fun SettingsHub( GroupedRow( title = stringResource(R.string.settings_section_notifications), summary = stringResource(R.string.settings_notifications_subtitle), - position = Position.Middle, + position = Position.Bottom, leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, onClick = { onOpenSection(SettingsSection.Notifications) }, ) + + Spacer(Modifier.height(8.dp)) + SectionHeader(stringResource(R.string.settings_group_data)) GroupedRow( title = stringResource(R.string.settings_section_calendars), summary = stringResource(R.string.settings_manage_calendars_hint), - position = Position.Middle, + position = Position.Top, leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) }, onClick = onManageCalendars, ) @@ -292,18 +221,57 @@ private fun SettingsHub( title = stringResource(R.string.settings_section_special_dates), summary = stringResource(R.string.settings_special_dates_subtitle), position = Position.Middle, - leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Tertiary) }, + leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Primary) }, onClick = { onOpenSection(SettingsSection.SpecialDates) }, ) + // Export/import used to hide inside the calendar manager, where nobody + // looked for it (#69). It is a data-safety feature, so it gets its own + // top-level entry; the manager keeps a pointer row to here. + GroupedRow( + title = stringResource(R.string.settings_section_backup), + summary = stringResource(R.string.settings_backup_subtitle), + position = Position.Bottom, + leading = { CategoryIcon(Icons.Default.Backup, ChipAccent.Secondary) }, + onClick = onOpenBackup, + ) + + Spacer(Modifier.height(8.dp)) + SectionHeader(stringResource(R.string.settings_group_app)) + GroupedRow( + title = stringResource(R.string.settings_section_widgets), + summary = stringResource(R.string.settings_widgets_subtitle), + position = Position.Top, + leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) }, + onClick = { onOpenSection(SettingsSection.Widgets) }, + ) LanguageRow(position = Position.Middle) - // One-tap add of the "New event" Quick Settings tile. The system prompt - // is API 33+; on older versions the tile is still addable manually from - // the QS editor, so the row simply doesn't appear here. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - QuickSettingsTileRow(position = Position.Middle) - } ReportProblemRow(position = Position.Bottom) + // Source, licence and privacy sit at the very bottom rather than in the + // About card: they are reference material you look up once, not settings + // you come here to change, and the card at the top now leads with what + // the app *is* plus the one link worth offering (support). + Spacer(Modifier.height(8.dp)) + SectionHeader(stringResource(R.string.settings_group_about)) + AboutLinkRow( + label = stringResource(R.string.settings_about_source), + url = stringResource(R.string.about_source_url), + icon = ImageVector.vectorResource(R.drawable.ic_gitea), + position = Position.Top, + ) + AboutLinkRow( + label = stringResource(R.string.settings_license), + url = stringResource(R.string.about_license_url), + icon = Icons.Default.Gavel, + position = Position.Middle, + ) + AboutLinkRow( + label = stringResource(R.string.settings_about_privacy), + url = stringResource(R.string.about_privacy_url), + icon = Icons.Default.PrivacyTip, + position = Position.Bottom, + ) + AppVersionText() } } @@ -343,24 +311,6 @@ private fun ReportProblemRow(position: Position) { } } -/** - * Asks the system to add the "New event" Quick Settings tile (API 33+). A - * top-level action row, like [ReportProblemRow] — the tile is a system-surface - * shortcut, not part of any one settings category. - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -@Composable -private fun QuickSettingsTileRow(position: Position) { - val context = LocalContext.current - GroupedRow( - title = stringResource(R.string.settings_qs_tile), - summary = stringResource(R.string.settings_qs_tile_hint), - position = position, - leading = { CategoryIcon(Icons.Default.Dashboard, ChipAccent.Neutral) }, - onClick = { requestAddQsTile(context) }, - ) -} - @Composable private fun LanguageRow(position: Position) { val context = LocalContext.current @@ -376,7 +326,7 @@ private fun LanguageRow(position: Position) { title = stringResource(R.string.settings_language), summary = languageLabel(current), position = position, - leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) }, + leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) }, onClick = { showDialog = true }, ) @@ -408,38 +358,57 @@ private fun LanguageRow(position: Position) { } } +@Composable +private fun languageLabel(tag: String?): String = + if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) + @Composable private fun AboutCard() { // The card layout lives in floret-kit (components.AboutCard); Calendula - // supplies its own logo, author and the source / licence / privacy / support - // links. The privacy policy has to be reachable from inside the app, not just - // from the store listing, because Calendula touches calendar and contact data. + // supplies its own logo and author. Source, licence and privacy moved to + // rows at the foot of the page, and support is now the [SupportRow] joined + // to the bottom of this card — so the card itself carries nothing but what + // the app is and who made it. AboutCard( logo = { AppLogo() }, appName = stringResource(R.string.app_name), author = stringResource(R.string.settings_about_author), - primaryLinks = listOf( - AboutLink( - icon = ImageVector.vectorResource(R.drawable.ic_gitea), - label = stringResource(R.string.settings_about_source), - url = stringResource(R.string.about_source_url), - ), - AboutLink( - icon = Icons.Default.Gavel, - label = stringResource(R.string.settings_license), - url = stringResource(R.string.about_license_url), - ), - AboutLink( - icon = Icons.Default.PrivacyTip, - label = stringResource(R.string.settings_about_privacy), - url = stringResource(R.string.about_privacy_url), - ), - ), - highlightLink = AboutLink( - icon = Icons.Default.Favorite, - label = stringResource(R.string.settings_about_support), - url = stringResource(R.string.about_support_url), - ), + primaryLinks = emptyList(), + position = Position.Top, + ) +} + +/** + * "Support development", as the closing row of the About card's group rather + * than a tonal button inside it. Same weight as any other row you can tap, with + * a primary chip to keep it the one accent in this block. + */ +@Composable +private fun SupportRow() { + val context = LocalContext.current + val url = stringResource(R.string.about_support_url) + GroupedRow( + title = stringResource(R.string.settings_about_support), + position = Position.Bottom, + leading = { CategoryIcon(Icons.Default.Favorite, ChipAccent.Primary) }, + onClick = { openUrl(context, url) }, + ) +} + +/** + * One reference link at the foot of the hub (source, licence, privacy): a plain + * grouped row that opens the URL in the browser. The privacy policy in + * particular has to stay reachable from inside the app, not only from the store + * listing, because Calendula touches calendar and contact data. + */ +@Composable +private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position: Position) { + val context = LocalContext.current + GroupedRow( + title = label, + position = position, + leading = { CategoryIcon(icon, ChipAccent.Neutral) }, + onClick = { openUrl(context, url) }, ) } @@ -467,1642 +436,36 @@ private fun AppVersionText() { * The app icon as a rounded chip: the off-white launcher mark over its slate * background colour, rendered oversized and clipped to fill the chip the way a * launcher mask would. + * + * Sized just past the two text lines beside it, so the card's height is set by + * its content rather than by the mark towering over it. That also keeps it a + * step above the 40dp category chips on the rows below — still the block's + * anchor, no longer its outlier. The mark is a vector, so the size is purely a + * layout choice; [MARK_OVERSCAN] holds the launcher-mask crop as it shrinks. */ @Composable private fun AppLogo() { Box( modifier = Modifier - .size(72.dp) - .clip(RoundedCornerShape(20.dp)) + .size(LOGO_SIZE) + .clip(RoundedCornerShape(16.dp)) .background(colorResource(R.color.ic_launcher_background)), contentAlignment = Alignment.Center, ) { Image( painter = painterResource(R.drawable.ic_launcher_foreground), contentDescription = stringResource(R.string.settings_about_logo_desc), - modifier = Modifier.requiredSize(108.dp), + modifier = Modifier.requiredSize(LOGO_SIZE * MARK_OVERSCAN), ) } } -// --------------------------------------------------------------------------- -// Sub-screens -// --------------------------------------------------------------------------- - -@Composable -private fun AppearanceScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, - onBack: () -> Unit, -) { - var showTheme by remember { mutableStateOf(false) } - var showWeekStart by remember { mutableStateOf(false) } - var showTimeFormat by remember { mutableStateOf(false) } - var showDefaultView by remember { mutableStateOf(false) } - var showAgendaScreenRange by remember { mutableStateOf(false) } - var showAgendaWidgetRange by remember { mutableStateOf(false) } - var showWidgetSize by remember { mutableStateOf(false) } - var showPastEvents by remember { mutableStateOf(false) } - var showBrandFont by remember { mutableStateOf(false) } - var showPlainFont by remember { mutableStateOf(false) } - var showAppName by remember { mutableStateOf(false) } - - val fonts by viewModel.fontState.collectAsStateWithLifecycle() - val launcherName by viewModel.launcherName.collectAsStateWithLifecycle() - // A picked file that didn't parse as a font: tell the user and keep the old choice. - val context = LocalContext.current - val importFailedMessage = stringResource(R.string.settings_font_import_failed) - LaunchedEffect(Unit) { - viewModel.fontImportFailed.collect { - Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show() - } - } - - CollapsingScaffold( - title = stringResource(R.string.settings_section_appearance), - onBack = onBack, - predictiveBack = true, - ) { - // Theme & colour - GroupedRow( - title = stringResource(R.string.settings_theme), - summary = themeLabel(state.themeMode), - position = Position.Top, - onClick = { showTheme = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_dynamic_color), - summary = if (state.dynamicColorAvailable) { - null - } else { - stringResource(R.string.settings_dynamic_color_unavailable) - }, - position = Position.Middle, - trailing = { - Switch( - checked = state.dynamicColor, - onCheckedChange = viewModel::setDynamicColor, - enabled = state.dynamicColorAvailable, - ) - }, - onClick = if (state.dynamicColorAvailable) { - { viewModel.setDynamicColor(!state.dynamicColor) } - } else { - null - }, - ) - GroupedRow( - title = stringResource(R.string.settings_soften_colors), - summary = stringResource(R.string.settings_soften_colors_summary), - position = Position.Bottom, - trailing = { - Switch( - checked = state.softenColors, - onCheckedChange = viewModel::setSoftenColors, - ) - }, - onClick = { viewModel.setSoftenColors(!state.softenColors) }, - ) - - Spacer(Modifier.height(16.dp)) - - // Fonts — the two Material typeface roles, each independently choosable - // (issue #19). Headings = brand (display/headline); body = plain - // (title/body/label). Both default to the system typeface. - GroupedRow( - title = stringResource(R.string.settings_font_headings), - summary = fontLabel(fonts.brand), - position = Position.Top, - onClick = { showBrandFont = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_font_body), - summary = fontLabel(fonts.plain), - position = Position.Bottom, - onClick = { showPlainFont = true }, - ) - - Spacer(Modifier.height(16.dp)) - - // Calendar — view, week, and timeline formatting - GroupedRow( - title = stringResource(R.string.settings_default_view), - summary = stringResource(state.defaultView.labelRes), - position = Position.Top, - onClick = { showDefaultView = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_today_toolbar), - summary = stringResource(R.string.settings_today_toolbar_summary), - position = Position.Middle, - trailing = { - Switch( - checked = state.todayButtonInToolbar, - onCheckedChange = viewModel::setTodayButtonInToolbar, - ) - }, - onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) }, - ) - GroupedRow( - title = stringResource(R.string.settings_week_start), - summary = weekStartLabel(state.weekStart), - 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), - position = Position.Middle, - onClick = { showTimeFormat = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_hour_lines), - summary = stringResource(R.string.settings_hour_lines_summary), - position = Position.Middle, - trailing = { - Switch( - checked = state.showHourLines, - onCheckedChange = viewModel::setShowHourLines, - ) - }, - onClick = { viewModel.setShowHourLines(!state.showHourLines) }, - ) - GroupedRow( - title = stringResource(R.string.settings_dim_completed), - summary = stringResource(R.string.settings_dim_completed_summary), - position = Position.Bottom, - trailing = { - Switch( - checked = state.dimCompletedEvents, - onCheckedChange = viewModel::setDimCompletedEvents, - ) - }, - onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) }, - ) - - Spacer(Modifier.height(16.dp)) - - // Agenda — the only group labelled, so its agenda-specific rows are easy - // to pick out among the otherwise unheadered Appearance settings. - SectionHeader(stringResource(R.string.settings_agenda_header)) - GroupedRow( - title = stringResource(R.string.settings_agenda_range), - summary = agendaRangeLabel(state.agendaScreenRange), - position = Position.Top, - onClick = { showAgendaScreenRange = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_past_events), - summary = pastEventDisplayLabel(state.pastEventDisplay), - position = Position.Middle, - onClick = { showPastEvents = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_agenda_widget_range), - summary = agendaRangeLabel(state.agendaWidgetRange), - position = Position.Middle, - onClick = { showAgendaWidgetRange = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_agenda_show_today), - summary = stringResource(R.string.settings_agenda_show_today_hint), - position = Position.Middle, - trailing = { - Switch( - checked = state.agendaShowToday, - onCheckedChange = viewModel::setAgendaShowToday, - ) - }, - onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) }, - ) - GroupedRow( - title = stringResource(R.string.settings_widget_size), - summary = widgetSizeLabel(state.widgetSize), - position = Position.Middle, - onClick = { showWidgetSize = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_agenda_range_bar), - summary = stringResource(R.string.settings_agenda_range_bar_hint), - position = Position.Bottom, - trailing = { - Switch( - checked = state.agendaShowRangeBar, - onCheckedChange = viewModel::setAgendaShowRangeBar, - ) - }, - onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) }, - ) - - Spacer(Modifier.height(16.dp)) - - // App name — chooses the launcher label between "Calendula" and "Calendar" - // (issue #44). Own group: it's a launcher/system concern, not calendar - // formatting. A sub-page chooser (not a switch), matching the app's other - // "choose one" settings and leaving room for more names later. - GroupedRow( - title = stringResource(R.string.settings_app_name), - summary = launcherNameLabel(launcherName), - position = Position.Alone, - onClick = { showAppName = true }, - ) - } - - if (showAppName) { - FullScreenPicker( - title = stringResource(R.string.settings_app_name), - onDismiss = { showAppName = false }, - predictiveBack = true, - ) { - // Show both names as launcher-mark previews so the user sees what - // they'd switch to, not just the current state. Tapping applies - // immediately and highlights — the picker stays open so the change is - // visible; back exits. - Text( - text = stringResource(R.string.settings_app_name_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - ) - Spacer(Modifier.height(24.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - LauncherName.entries.forEach { option -> - AppNameOptionCard( - name = option, - selected = launcherName == option, - onClick = { viewModel.setLauncherName(option) }, - modifier = Modifier.weight(1f), - ) - } - } - } - } - if (showTheme) { - OptionPicker( - title = stringResource(R.string.settings_theme), - predictiveBack = true, - options = ThemeMode.entries, - selected = state.themeMode, - label = { themeLabel(it) }, - onSelect = viewModel::setThemeMode, - onDismiss = { showTheme = false }, - ) - } - if (showBrandFont) { - FontPicker( - title = stringResource(R.string.settings_font_headings), - role = FontRole.BRAND, - selected = fonts.brand, - stamp = fonts.brandStamp, - onSelect = { viewModel.setFont(FontRole.BRAND, it) }, - onImport = { viewModel.importCustomFont(FontRole.BRAND, it) }, - onDismiss = { showBrandFont = false }, - ) - } - if (showPlainFont) { - FontPicker( - title = stringResource(R.string.settings_font_body), - role = FontRole.PLAIN, - selected = fonts.plain, - stamp = fonts.plainStamp, - onSelect = { viewModel.setFont(FontRole.PLAIN, it) }, - onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) }, - onDismiss = { showPlainFont = false }, - ) - } - if (showWeekStart) { - OptionPicker( - title = stringResource(R.string.settings_week_start), - predictiveBack = true, - options = WEEK_START_OPTIONS, - selected = state.weekStart, - label = { weekStartLabel(it) }, - onSelect = viewModel::setWeekStart, - onDismiss = { showWeekStart = false }, - ) - } - if (showAgendaScreenRange) { - AgendaRangePicker( - title = stringResource(R.string.settings_agenda_range), - description = stringResource(R.string.settings_agenda_range_hint), - selected = state.agendaScreenRange, - onSelect = viewModel::setAgendaScreenRange, - onDismiss = { showAgendaScreenRange = false }, - ) - } - if (showAgendaWidgetRange) { - AgendaRangePicker( - title = stringResource(R.string.settings_agenda_widget_range), - description = stringResource(R.string.settings_agenda_widget_range_hint), - selected = state.agendaWidgetRange, - onSelect = viewModel::setAgendaWidgetRange, - onDismiss = { showAgendaWidgetRange = false }, - ) - } - if (showWidgetSize) { - OptionPicker( - title = stringResource(R.string.settings_widget_size), - header = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) }, - predictiveBack = true, - options = WidgetSize.entries, - selected = state.widgetSize, - label = { widgetSizeLabel(it) }, - onSelect = viewModel::setWidgetSize, - onDismiss = { showWidgetSize = false }, - ) - } - if (showTimeFormat) { - OptionPicker( - title = stringResource(R.string.settings_time_format), - predictiveBack = true, - options = TimeFormatPref.entries, - selected = state.timeFormat, - label = { timeFormatLabel(it) }, - onSelect = viewModel::setTimeFormat, - onDismiss = { showTimeFormat = false }, - ) - } - if (showPastEvents) { - OptionPicker( - title = stringResource(R.string.settings_past_events), - options = PastEventDisplay.entries, - selected = state.pastEventDisplay, - label = { pastEventDisplayLabel(it) }, - onSelect = viewModel::setPastEventDisplay, - onDismiss = { showPastEvents = false }, - ) - } - if (showDefaultView) { - OptionPicker( - title = stringResource(R.string.settings_default_view), - predictiveBack = true, - options = IMPLEMENTED_VIEWS, - selected = state.defaultView, - label = { stringResource(it.labelRes) }, - onSelect = viewModel::setDefaultView, - onDismiss = { showDefaultView = false }, - ) - } -} +/** Roughly the height of the app name and author lines it sits beside. */ +private val LOGO_SIZE = 56.dp /** - * Views (#24): reorder the top-bar quick-switch cycle and choose which views it - * steps through, plus reorder the navigation-drawer list. Two independent lists — - * a view disabled in the quick-switch cycle is still reachable from the drawer, - * which always lists every view. The switch needs at least two targets, so the - * last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off. + * How far the mark overruns its chip. An adaptive icon's foreground is drawn + * with a wide safe margin, so at 1:1 the flower would sit small and lost inside + * the chip; the launcher crops the same way. */ -@Composable -private fun ViewsScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, - onBack: () -> Unit, -) { - var showMonthStyle by remember { mutableStateOf(false) } - - CollapsingScaffold( - title = stringResource(R.string.settings_section_views), - onBack = onBack, - predictiveBack = true, - ) { - val config = state.quickSwitchConfig - - // Per-view layout, above the cross-view switcher/order settings below. - SectionHeader(stringResource(R.string.settings_month_header)) - GroupedRow( - title = stringResource(R.string.settings_month_view_style), - summary = stringResource(state.monthViewStyle.labelRes), - position = Position.Alone, - onClick = { showMonthStyle = true }, - ) - - Spacer(Modifier.height(24.dp)) - SectionHeader(stringResource(R.string.settings_quick_switch_header)) - SettingsHint(stringResource(R.string.settings_quick_switch_hint)) - Spacer(Modifier.height(8.dp)) - // Turning a view off is blocked once only the minimum remain enabled. - val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED - ReorderableColumn( - items = config.order, - keyOf = { it }, - onReorder = { viewModel.setQuickSwitchOrder(it) }, - ) { view, position, dragHandle, isDragging -> - val checked = view in config.enabled - ViewRow( - view = view, - position = position, - isDragging = isDragging, - dragHandle = dragHandle, - dimmed = !checked, - trailing = { - Switch( - checked = checked, - // Keep the last two on: with fewer, the pill can't switch. - enabled = !checked || canDisable, - onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) }, - ) - }, - ) - } - - Spacer(Modifier.height(24.dp)) - SectionHeader(stringResource(R.string.settings_drawer_order_header)) - SettingsHint(stringResource(R.string.settings_drawer_order_hint)) - Spacer(Modifier.height(8.dp)) - ReorderableColumn( - items = state.drawerViewOrder, - keyOf = { it }, - onReorder = { viewModel.setDrawerViewOrder(it) }, - ) { view, position, dragHandle, isDragging -> - ViewRow( - view = view, - position = position, - isDragging = isDragging, - dragHandle = dragHandle, - ) - } - } - - if (showMonthStyle) { - MonthViewStylePicker( - selected = state.monthViewStyle, - // The preview is a real grid, so it uses the real week start too. - weekStart = state.weekStart.resolveFirstDay(currentLocale()), - onSelect = viewModel::setMonthViewStyle, - onDismiss = { showMonthStyle = false }, - ) - } -} - -/** One reorderable view row: the view's icon and name, an optional [trailing] - * control, and a drag handle carrying the [dragHandle] gesture modifier. */ -@Composable -private fun ViewRow( - view: CalendarView, - position: Position, - isDragging: Boolean, - dragHandle: Modifier, - dimmed: Boolean = false, - trailing: @Composable (() -> Unit)? = null, -) { - GroupedRow( - title = stringResource(view.labelRes), - position = position, - dimmed = dimmed, - minHeight = ReorderableRowHeight, - // The reorderable column owns the inter-row spacing (uniform pitch). - gapBelow = false, - container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null, - leading = { - Icon( - imageVector = view.icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailing = { - Row(verticalAlignment = Alignment.CenterVertically) { - trailing?.invoke() - if (trailing != null) Spacer(Modifier.width(8.dp)) - Box( - modifier = dragHandle.size(48.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.DragHandle, - contentDescription = stringResource(R.string.reorder_drag_handle), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, - ) -} - -/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */ -@Composable -private fun SettingsHint(text: String) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) -} - -@Composable -private fun EventFormScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, - onBack: () -> Unit, -) { - CollapsingScaffold( - title = stringResource(R.string.settings_section_event_form), - onBack = onBack, - predictiveBack = true, - ) { - Text( - text = stringResource(R.string.settings_form_fields_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - Spacer(Modifier.height(8.dp)) - val fields = EventFormField.entries - fields.forEachIndexed { index, field -> - val checked = field in state.defaultFormFields - GroupedRow( - title = stringResource(eventFormFieldLabel(field)), - position = positionOf(index, fields.size), - // Same icon the field carries in the new-event form, so a toggle - // is easy to match to the field it controls. - leading = { - Icon( - imageVector = eventFormFieldIcon(field), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailing = { - Switch( - checked = checked, - onCheckedChange = { viewModel.setFormFieldDefault(field, it) }, - ) - }, - onClick = { viewModel.setFormFieldDefault(field, !checked) }, - ) - } - - // Auto-focus the title on a new event (issue #10) — on by default, since - // most events get a title; raising the keyboard saves a tap. Off lets you - // set the time/calendar first without the keyboard in the way. - Spacer(Modifier.height(24.dp)) - GroupedRow( - title = stringResource(R.string.settings_autofocus_title), - summary = stringResource(R.string.settings_autofocus_title_hint), - position = Position.Alone, - leading = { - Icon( - imageVector = Icons.Default.Keyboard, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailing = { - Switch( - checked = state.autofocusEventTitle, - onCheckedChange = { viewModel.setAutofocusEventTitle(it) }, - ) - }, - onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) }, - ) - - // Per-event colour on calendars that publish no colour set (some - // CalDAV) — off by default, with the honest caveat that the colour may - // not survive their next sync. Local and palette calendars ignore it. - Spacer(Modifier.height(24.dp)) - GroupedRow( - title = stringResource(R.string.settings_color_unsupported), - summary = stringResource(R.string.settings_color_unsupported_hint), - position = Position.Alone, - trailing = { - Switch( - checked = state.allowColorOnUnsupportedCalendars, - onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) }, - ) - }, - onClick = { - viewModel.setAllowColorOnUnsupportedCalendars( - !state.allowColorOnUnsupportedCalendars, - ) - }, - ) - } -} - -/** - * Ask the system to add the "New event" Quick Settings tile (API 33+). The OS - * shows its own confirmation dialog and handles the already-added case, so no - * result handling is needed here. - */ -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -private fun requestAddQsTile(context: Context) { - val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return - statusBar.requestAddTileService( - ComponentName(context, NewEventTileService::class.java), - context.getString(R.string.qs_tile_new_event_label), - Icon.createWithResource(context, R.drawable.ic_qs_new_event), - context.mainExecutor, - ) { /* result code unused — the system surfaces its own feedback */ } -} - -/** - * Reminder-notifications toggle (v1.4), mirroring the onboarding step. - * Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) — - * the pref is set either way; the OS permission is the real gate. - */ -@Composable -private fun NotificationsScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, - onBack: () -> Unit, - onOpenSpecialDates: () -> Unit, -) { - val context = LocalContext.current - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { /* The pref is already on; a denial just leaves the OS gate shut. */ } - val toggleReminders: (Boolean) -> Unit = { enabled -> - viewModel.setRemindersEnabled(enabled) - val needsPermission = enabled && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission( - context, Manifest.permission.POST_NOTIFICATIONS, - ) != PackageManager.PERMISSION_GRANTED - if (needsPermission) { - launcher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - - var showDefaultReminder by remember { mutableStateOf(false) } - var showAllDayReminder by remember { mutableStateOf(false) } - var showAllDayReminderTime by remember { mutableStateOf(false) } - var showSnooze by remember { mutableStateOf(false) } - var overrideDialog by remember { mutableStateOf(null) } - var calendarSectionExpanded by remember { mutableStateOf(false) } - var expandedCalendars by remember { mutableStateOf(emptySet()) } - - CollapsingScaffold( - title = stringResource(R.string.settings_section_notifications), - onBack = onBack, - predictiveBack = true, - ) { - GroupedRow( - title = stringResource(R.string.settings_reminders), - summary = stringResource(R.string.settings_reminders_hint), - position = Position.Top, - trailing = { - Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders) - }, - onClick = { toggleReminders(!state.remindersEnabled) }, - ) - GroupedRow( - title = stringResource(R.string.settings_default_reminder), - summary = reminderChoiceLabel(state.defaultReminderMinutes), - position = Position.Middle, - onClick = { showDefaultReminder = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_default_reminder_allday), - summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes), - position = Position.Middle, - onClick = { showAllDayReminder = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_allday_reminder_time), - summary = stringResource( - R.string.settings_allday_reminder_time_hint, - formatTimeOfDay(context, state.allDayReminderTimeMinutes), - ), - position = Position.Bottom, - onClick = { showAllDayReminderTime = true }, - ) - - // Delivery reliability + snooze: both are global reminder-delivery - // settings, so they sit with the defaults above rather than below the - // long per-calendar list. Reliability is a soft, optional battery- - // optimisation exemption (system-settings deep-link, no special - // permission); shown as live status, reversible at any time. - Spacer(Modifier.height(24.dp)) - val batteryExempt = rememberBatteryOptimizationExempt() - GroupedRow( - title = stringResource(R.string.settings_reliable_delivery), - summary = if (batteryExempt) { - stringResource(R.string.settings_reliable_delivery_exempt) - } else { - stringResource(R.string.settings_reliable_delivery_hint) - }, - position = Position.Top, - trailing = if (batteryExempt) { - { SelectedCheck() } - } else { - null - }, - onClick = { openBatteryOptimizationSettings(context) }, - ) - - // Snooze: how long the notification's "Snooze" action defers a reminder. - GroupedRow( - title = stringResource(R.string.settings_snooze_duration), - summary = snoozeDurationLabel(state.snoozeMinutes), - position = Position.Bottom, - onClick = { showSnooze = true }, - ) - - // Per-calendar overrides: the whole section folds behind one header to - // keep the screen tidy. Expanded, each writable calendar gets its own - // expandable card that may keep, drop, or replace the global default — - // separately for timed and all-day events. - if (state.writableCalendars.isNotEmpty()) { - Spacer(Modifier.height(24.dp)) - GroupedRow( - title = stringResource(R.string.settings_calendar_reminders_title), - summary = stringResource(R.string.settings_calendar_reminders_hint), - position = Position.Alone, - trailing = { - Icon( - imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - onClick = { calendarSectionExpanded = !calendarSectionExpanded }, - ) - AnimatedVisibility( - visible = calendarSectionExpanded, - enter = expandEnter(), - exit = collapseExit(), - ) { - Column { - state.writableCalendars.forEach { calendar -> - Spacer(Modifier.height(16.dp)) - // A contact special-dates calendar owns its reminders in - // its own section — link there instead of an override. - if (calendar.id in state.managedCalendarIds) { - GroupedRow( - title = calendar.displayName, - summary = stringResource(R.string.settings_calendar_reminders_managed_hint), - position = Position.Alone, - leading = { CalendarColorChip(calendar.color) }, - trailing = { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - onClick = onOpenSpecialDates, - ) - return@forEach - } - val expanded = calendar.id in expandedCalendars - // Calendar card; tapping expands it into a grouped list - // of three (the card + the timed and all-day rows). - GroupedRow( - title = calendar.displayName, - position = if (expanded) Position.Top else Position.Alone, - leading = { CalendarColorChip(calendar.color) }, - trailing = { - Icon( - imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - onClick = { - expandedCalendars = if (expanded) { - expandedCalendars - calendar.id - } else { - expandedCalendars + calendar.id - } - }, - ) - AnimatedVisibility( - visible = expanded, - enter = expandEnter(), - exit = collapseExit(), - ) { - Column { - val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id) - GroupedRow( - title = stringResource(R.string.settings_default_reminder), - summary = calendarOverrideSummary(timed, state.defaultReminderMinutes), - position = Position.Middle, - onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) }, - ) - val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id) - GroupedRow( - title = stringResource(R.string.settings_default_reminder_allday), - summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes), - position = Position.Bottom, - onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) }, - ) - } - } - } - } - } - } - } - - if (showSnooze) { - SnoozeDurationPicker( - title = stringResource(R.string.settings_snooze_duration), - presets = SNOOZE_PRESETS, - selected = state.snoozeMinutes, - label = { snoozeDurationLabel(it) }, - onSelect = { viewModel.setSnoozeMinutes(it) }, - onDismiss = { showSnooze = false }, - ) - } - if (showDefaultReminder) { - ReminderDefaultPicker( - title = stringResource(R.string.settings_default_reminder), - presets = REMINDER_PRESETS, - selected = state.defaultReminderMinutes.toReminderChoice(), - allowInherit = false, - onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) }, - onDismiss = { showDefaultReminder = false }, - ) - } - if (showAllDayReminder) { - ReminderDefaultPicker( - title = stringResource(R.string.settings_default_reminder_allday), - presets = ALLDAY_REMINDER_PRESETS, - selected = state.defaultAllDayReminderMinutes.toReminderChoice(), - allowInherit = false, - onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) }, - onDismiss = { showAllDayReminder = false }, - ) - } - if (showAllDayReminderTime) { - TimePickerAlert( - initial = LocalTime( - state.allDayReminderTimeMinutes / 60, - state.allDayReminderTimeMinutes % 60, - ), - onConfirm = { - viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute) - showAllDayReminderTime = false - }, - onDismiss = { showAllDayReminderTime = false }, - ) - } - overrideDialog?.let { target -> - val map = if (target.isAllDay) { - state.perCalendarAllDayReminderOverride - } else { - state.perCalendarReminderOverride - } - ReminderDefaultPicker( - title = stringResource( - if (target.isAllDay) { - R.string.settings_default_reminder_allday - } else { - R.string.settings_default_reminder - }, - ), - presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS, - selected = map.reminderOverrideFor(target.calendarId), - allowInherit = true, - onSelect = { - if (target.isAllDay) { - viewModel.setCalendarAllDayReminderOverride(target.calendarId, it) - } else { - viewModel.setCalendarReminderOverride(target.calendarId, it) - } - }, - onDismiss = { overrideDialog = null }, - ) - } -} - -// --------------------------------------------------------------------------- -// Contact special dates (issue #15) -// --------------------------------------------------------------------------- - -@Composable -private fun SpecialDatesScreen( - viewModel: SettingsViewModel, - onBack: () -> Unit, -) { - val state by viewModel.specialDatesState.collectAsStateWithLifecycle() - val context = LocalContext.current - - // READ_CONTACTS is requested only here, on enable — never at startup. On a - // grant we either enable (if turning on) or just re-sync (clearing a stalled - // banner after the permission was re-granted). - val permissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { granted -> - if (granted) { - if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow() - } - } - val requestOrEnable: () -> Unit = { - if (context.hasContactsPermission()) { - viewModel.setSpecialDatesEnabled(true) - } else { - permissionLauncher.launch(Manifest.permission.READ_CONTACTS) - } - } - - var confirmDisableAll by remember { mutableStateOf(false) } - var confirmDisableType by remember { mutableStateOf(null) } - var editTemplate by remember { mutableStateOf(null) } - var reminderPickerType by remember { mutableStateOf(null) } - - CollapsingScaffold( - title = stringResource(R.string.settings_section_special_dates), - onBack = onBack, - predictiveBack = true, - ) { - // Paused banner: the permission was revoked after enabling. - if (state.enabled && state.stalledPermission) { - Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.errorContainer, - modifier = Modifier.fillMaxWidth(), - ) { - Column(Modifier.padding(16.dp)) { - Text( - text = stringResource(R.string.settings_special_dates_paused_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - text = stringResource(R.string.settings_special_dates_paused_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Spacer(Modifier.height(8.dp)) - FilledTonalButton( - onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) }, - ) { Text(stringResource(R.string.settings_special_dates_grant)) } - } - } - Spacer(Modifier.height(16.dp)) - } - - GroupedRow( - title = stringResource(R.string.settings_special_dates_enable), - summary = stringResource(R.string.settings_special_dates_enable_hint), - position = Position.Alone, - trailing = { - Switch( - checked = state.enabled, - onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true }, - ) - }, - onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() }, - ) - - if (state.enabled) { - // Per-type card: the toggle, and while on, its title format and its - // calendar-wide reminder. - SpecialDateType.entries.forEachIndexed { index, type -> - Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp)) - val on = type in state.types - GroupedRow( - title = stringResource(specialDateTypeLabel(type)), - position = if (on) Position.Top else Position.Alone, - trailing = { - Switch( - checked = on, - onCheckedChange = { want -> - if (want) viewModel.setSpecialDateTypeEnabled(type, true) - else confirmDisableType = type - }, - ) - }, - onClick = { - if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true) - }, - ) - if (on) { - GroupedRow( - title = stringResource(R.string.settings_special_dates_template), - summary = state.titleTemplates[type].orEmpty(), - position = Position.Middle, - onClick = { editTemplate = type }, - ) - GroupedRow( - title = stringResource(R.string.settings_special_dates_reminders), - summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])), - position = Position.Bottom, - onClick = { reminderPickerType = type }, - ) - } - } - - Spacer(Modifier.height(24.dp)) - GroupedRow( - title = stringResource(R.string.settings_special_dates_show_year), - summary = stringResource(R.string.settings_special_dates_show_year_hint), - position = Position.Top, - trailing = { - Switch( - checked = state.showYear, - onCheckedChange = viewModel::setSpecialDatesShowYear, - ) - }, - onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) }, - ) - GroupedRow( - title = stringResource(R.string.settings_special_dates_sync_now), - summary = specialDatesLastRunLabel(context, state.lastRun), - position = Position.Bottom, - onClick = viewModel::syncSpecialDatesNow, - ) - - Spacer(Modifier.height(24.dp)) - Text( - text = stringResource(R.string.settings_special_dates_calendar_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - } - - if (confirmDisableAll) { - SpecialDatesDisableDialog( - message = stringResource(R.string.settings_special_dates_disable_all_message), - onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false }, - onDismiss = { confirmDisableAll = false }, - ) - } - confirmDisableType?.let { type -> - SpecialDatesDisableDialog( - message = stringResource( - R.string.settings_special_dates_disable_type_message, - stringResource(specialDateTypeLabel(type)), - ), - onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null }, - onDismiss = { confirmDisableType = null }, - ) - } - editTemplate?.let { type -> - SpecialDatesTemplateDialog( - initial = state.titleTemplates[type].orEmpty(), - onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null }, - onDismiss = { editTemplate = null }, - ) - } - reminderPickerType?.let { type -> - ReminderDefaultPicker( - title = stringResource(R.string.settings_special_dates_reminders), - presets = ALLDAY_REMINDER_PRESETS, - selected = state.reminderChoices[type] ?: ReminderOverride.None, - // Managed calendars own their reminders outright — no "inherit global". - allowInherit = false, - onSelect = { viewModel.setSpecialDatesReminders(type, it) }, - onDismiss = { reminderPickerType = null }, - ) - } -} - -/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */ -private fun specialDatesReminderMinutes(choice: ReminderOverride?): List = - (choice as? ReminderOverride.Minutes)?.minutes.orEmpty() - -@Composable -private fun SpecialDatesDisableDialog( - message: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - androidx.compose.material3.AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.settings_special_dates_disable_title)) }, - text = { Text(message) }, - confirmButton = { - androidx.compose.material3.TextButton(onClick = onConfirm) { - Text(stringResource(R.string.settings_special_dates_disable_confirm)) - } - }, - dismissButton = { - androidx.compose.material3.TextButton(onClick = onDismiss) { - Text(stringResource(R.string.dialog_cancel)) - } - }, - ) -} - -@Composable -private fun SpecialDatesTemplateDialog( - initial: String, - onConfirm: (String) -> Unit, - onDismiss: () -> Unit, -) { - var text by rememberSaveable { mutableStateOf(initial) } - androidx.compose.material3.AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.settings_special_dates_template)) }, - text = { - Column { - // The app's borderless input over a tonal surface (the dialog - // convention — see DialogControls), not Material's outlined field. - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = RoundedCornerShape(12.dp), - ) { - InlineTextField( - value = text, - onValueChange = { text = it }, - placeholder = stringResource(R.string.settings_special_dates_template), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - ) - } - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(R.string.settings_special_dates_template_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - confirmButton = { - androidx.compose.material3.TextButton( - onClick = { onConfirm(text) }, - enabled = text.isNotBlank(), - ) { Text(stringResource(R.string.dialog_save)) } - }, - dismissButton = { - androidx.compose.material3.TextButton(onClick = onDismiss) { - Text(stringResource(R.string.dialog_cancel)) - } - }, - ) -} - -private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) { - SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday - SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary - SpecialDateType.Custom -> R.string.settings_special_dates_type_custom -} - -@Composable -private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String = - if (lastRun <= 0L) { - stringResource(R.string.settings_special_dates_never_synced) - } else { - stringResource( - R.string.settings_special_dates_last_synced, - android.text.format.DateUtils.getRelativeTimeSpanString( - lastRun, - System.currentTimeMillis(), - android.text.format.DateUtils.MINUTE_IN_MILLIS, - ).toString(), - ) - } - -/** Which calendar + event kind a per-calendar reminder-override dialog targets. */ -private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean) - -/** A global default (empty = none) as a picker choice for selection highlighting. */ -private fun List.toReminderChoice(): ReminderOverride = - if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this) - -/** A picked choice as global-default minutes (Inherit isn't offered for globals). */ -private fun ReminderOverride.toMinutesList(): List = - (this as? ReminderOverride.Minutes)?.minutes ?: emptyList() - -/** - * Whether Calendula is exempt from battery optimisation, re-read on every - * `ON_RESUME` so the row reflects a change the user just made in system - * settings without needing to leave and re-enter the screen. - */ -@Composable -private fun rememberBatteryOptimizationExempt(): Boolean { - val context = LocalContext.current - var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - exempt = isIgnoringBatteryOptimizations(context) - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - return exempt -} - -private fun isIgnoringBatteryOptimizations(context: Context): Boolean { - val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager - return power.isIgnoringBatteryOptimizations(context.packageName) -} - -/** - * Take the user straight to Calendula's exemption: the direct - * `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore - * battery optimisation?") rather than the full app list they'd have to scroll. - * Falls back to the optimisation list if the OS refuses the direct intent. - */ -private fun openBatteryOptimizationSettings(context: Context) { - val direct = Intent( - Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - "package:${context.packageName}".toUri(), - ) - if (runCatching { context.startActivity(direct) }.isFailure) { - runCatching { - context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) - } - } -} - -/** - * Lead times offered for the all-day default — day-scale, since a "minutes - * before midnight" reminder on an all-day event is rarely what's wanted. - */ -private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080) - -/** Snooze delays offered for the notification "Snooze" action, in minutes. */ -private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60) - -/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */ -@Composable -private fun snoozeDurationLabel(minutes: Int): String = - if (minutes % 60 == 0) { - pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60) - } else { - pluralStringResource(R.plurals.duration_minutes, minutes, minutes) - } - -/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */ -private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String { - val time = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, minutesOfDay / 60) - set(Calendar.MINUTE, minutesOfDay % 60) - }.time - return DateFormat.getTimeFormat(context).format(time) -} - -/** Label for a global-default choice: empty → "None", else the lead times joined. */ -@Composable -private fun reminderChoiceLabel(minutes: List): String { - if (minutes.isEmpty()) return stringResource(R.string.reminder_none) - return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ") -} - -/** Row summary for a calendar: its override, or the inherited global default. */ -@Composable -private fun calendarOverrideSummary( - choice: ReminderOverride, - globalDefault: List, -): String = when (choice) { - ReminderOverride.Inherit -> - stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault)) - ReminderOverride.None -> stringResource(R.string.reminder_none) - is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes) -} - -// --------------------------------------------------------------------------- -// Shared building blocks -// --------------------------------------------------------------------------- - -/** - * Leading circular icon chip. Colours come from the M3 scheme via a container / - * on-container token pair, so each accent stays correctly paired across theme, - * dark mode and dynamic colour. - */ -@Composable -private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) { - val scheme = MaterialTheme.colorScheme - val (background, iconColor) = when (accent) { - ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant - ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer - ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer - } - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(background), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconColor, - modifier = Modifier.size(22.dp), - ) - } -} - - -private fun openUrl(context: Context, url: String) { - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - runCatching { context.startActivity(intent) } -} - -/** The display name for a launcher-label choice (issue #44). */ -@Composable -private fun launcherNameLabel(name: LauncherName): String = stringResource( - when (name) { - LauncherName.CALENDULA -> R.string.app_name - LauncherName.CALENDAR -> R.string.app_name_calendar_alias - }, -) - -/** - * One selectable launcher-name preview in the App name picker (issue #44): the - * app's launcher mark over the name, framed as a card. The active one carries a - * primary border, a tinted container and a check; tapping selects it. The mark - * is the same for both — only the label changes — so the card previews exactly - * what the home screen will read. - */ -@Composable -private fun AppNameOptionCard( - name: LauncherName, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val shape = RoundedCornerShape(24.dp) - val borderColor = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outlineVariant - } - val containerColor = if (selected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - } - Column( - modifier = modifier - .clip(shape) - .background(containerColor) - .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape) - .clickable(onClick = onClick) - .padding(vertical = 20.dp, horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - // The adaptive launcher mark, reconstructed as a squircle (as in the - // onboarding BrandHero) so it renders identically everywhere. - Box( - modifier = Modifier - .size(64.dp) - .clip(RoundedCornerShape(18.dp)) - .background(colorResource(R.color.ic_launcher_background)), - ) { - Image( - painter = painterResource(R.drawable.ic_launcher_foreground), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - ) - } - Text( - text = launcherNameLabel(name), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - maxLines = 1, - ) - // Selection indicator: a filled check when active, an empty ring otherwise. - Box( - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) - .then( - if (selected) { - Modifier - } else { - Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) - }, - ), - contentAlignment = Alignment.Center, - ) { - if (selected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(16.dp), - ) - } - } - } -} - -@Composable -private fun themeLabel(mode: ThemeMode): String = stringResource( - when (mode) { - ThemeMode.SYSTEM -> R.string.settings_theme_system - ThemeMode.LIGHT -> R.string.settings_theme_light - ThemeMode.DARK -> R.string.settings_theme_dark - }, -) - -/** The summary label for a stored font token (issue #19). */ -@Composable -private fun fontLabel(token: String): String = when (token) { - FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system) - FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected) - else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) } - ?: stringResource(R.string.settings_font_system) -} - -/** - * MIME types offered to the document picker so it lists only font files. Covers - * the modern `font/` types plus the legacy `application/` font aliases some - * providers still report. Anything that slips through is still validated by - * [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use. - */ -private val FONT_PICKER_MIME_TYPES = arrayOf( - "font/ttf", - "font/otf", - "font/sfnt", - "font/collection", - "application/x-font-ttf", - "application/x-font-otf", - "application/font-sfnt", - "application/vnd.ms-opentype", -) - -/** - * Full-screen font chooser for one [FontRole]: the system default, each bundled - * font (previewed in its own face), and "Choose file…" which opens the system - * picker to load a .ttf/.otf. Selecting a system/bundled option applies at once; - * a file is validated and imported by the caller, switching to the custom font - * on success. - */ -@Composable -private fun FontPicker( - title: String, - role: FontRole, - selected: String, - stamp: Int, - onSelect: (String) -> Unit, - onImport: (Uri) -> Unit, - onDismiss: () -> Unit, -) { - val context = LocalContext.current - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument(), - ) { uri -> - if (uri != null) { - onImport(uri) - onDismiss() - } - } - - // System default + the bundled fonts + the "Choose file…" row. - val rowCount = BundledFont.entries.size + 2 - val isCustom = selected == FONT_CUSTOM_TOKEN - // Resolving the custom face stats the disk and builds a fresh FontFamily, so - // memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file - // refreshes the preview while plain recompositions reuse the cached family. - val customPreview = remember(role, isCustom, stamp) { - if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null - } - - FullScreenPicker(title = title, onDismiss = onDismiss) { - FontOptionRow( - label = stringResource(R.string.settings_font_system), - preview = FontFamily.Default, - selected = selected == FONT_SYSTEM_TOKEN, - position = positionOf(0, rowCount), - onClick = { - onSelect(FONT_SYSTEM_TOKEN) - onDismiss() - }, - ) - BundledFont.entries.forEachIndexed { index, font -> - FontOptionRow( - label = stringResource(font.labelRes), - preview = font.family, - selected = selected == font.token, - position = positionOf(index + 1, rowCount), - onClick = { - onSelect(font.token) - onDismiss() - }, - ) - } - FontOptionRow( - label = if (isCustom) { - stringResource(R.string.settings_font_custom_selected) - } else { - stringResource(R.string.settings_font_choose_file) - }, - // A loaded font previews in its own face; otherwise show an upload cue. - preview = customPreview, - leadingIcon = if (isCustom) null else Icons.Default.UploadFile, - selected = isCustom, - position = positionOf(rowCount - 1, rowCount), - onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) }, - ) - } -} - -/** - * One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in - * the option's own [preview] face (or an [leadingIcon] cue when there's nothing - * to preview), and a check when it's the current selection. - */ -@Composable -private fun FontOptionRow( - label: String, - preview: FontFamily?, - selected: Boolean, - position: Position, - onClick: () -> Unit, - leadingIcon: ImageVector? = null, -) { - GroupedRow( - title = label, - position = position, - selected = selected, - leading = { - if (preview != null) { - Text( - text = "Ag", - fontFamily = preview, - style = MaterialTheme.typography.titleLarge, - ) - } else if (leadingIcon != null) { - Icon(imageVector = leadingIcon, contentDescription = null) - } - }, - trailing = if (selected) { - { SelectedCheck() } - } else { - null - }, - onClick = onClick, - ) -} - -/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */ -private val WEEK_START_OPTIONS: List = - listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) } - -@Composable -private fun weekStartLabel(pref: WeekStartPref): String = when (pref) { - WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto) - // Localised full weekday name, so any of the seven days reads naturally - // without a per-day string resource. - is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1) - .getDisplayName(JavaTextStyle.FULL, currentLocale()) -} - -@Composable -private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource( - when (pref) { - TimeFormatPref.AUTO -> R.string.settings_time_format_auto - TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h - TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h - }, -) - -/** A small primary-coloured group label, matching the Calendars settings screen. */ -@Composable -private fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), - ) -} - -@Composable -private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResource( - when (mode) { - PastEventDisplay.SHOW -> R.string.settings_past_events_show - PastEventDisplay.DIM -> R.string.settings_past_events_dim - PastEventDisplay.HIDE -> R.string.settings_past_events_hide - }, -) - -@Composable -private fun widgetSizeLabel(size: WidgetSize): String = stringResource( - when (size) { - WidgetSize.SMALL -> R.string.settings_widget_size_small - WidgetSize.MEDIUM -> R.string.settings_widget_size_medium - WidgetSize.LARGE -> R.string.settings_widget_size_large - WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large - }, -) - -@Composable -private fun languageLabel(tag: String?): String = - if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) +private const val MARK_OVERSCAN = 1.5f diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt new file mode 100644 index 0000000..5627285 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt @@ -0,0 +1,327 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import android.Manifest +import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission +import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType +import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.InlineTextField +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.reminders.ReminderOverride + +// --------------------------------------------------------------------------- +// Contact special dates (issue #15) +// --------------------------------------------------------------------------- + +@Composable +internal fun SpecialDatesScreen( + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + val state by viewModel.specialDatesState.collectAsStateWithLifecycle() + // The all-day reminder hour lives in the general settings state; the reminder + // picker below names it, since that is when these reminders actually fire. + val settings by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + // READ_CONTACTS is requested only here, on enable — never at startup. On a + // grant we either enable (if turning on) or just re-sync (clearing a stalled + // banner after the permission was re-granted). + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) { + if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow() + } + } + val requestOrEnable: () -> Unit = { + if (context.hasContactsPermission()) { + viewModel.setSpecialDatesEnabled(true) + } else { + permissionLauncher.launch(Manifest.permission.READ_CONTACTS) + } + } + + var confirmDisableAll by remember { mutableStateOf(false) } + var confirmDisableType by remember { mutableStateOf(null) } + var editTemplate by remember { mutableStateOf(null) } + var reminderPickerType by remember { mutableStateOf(null) } + + CollapsingScaffold( + title = stringResource(R.string.settings_section_special_dates), + onBack = onBack, + predictiveBack = true, + ) { + // Paused banner: the permission was revoked after enabling. + if (state.enabled && state.stalledPermission) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.settings_special_dates_paused_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringResource(R.string.settings_special_dates_paused_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Spacer(Modifier.height(8.dp)) + FilledTonalButton( + onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) }, + ) { Text(stringResource(R.string.settings_special_dates_grant)) } + } + } + Spacer(Modifier.height(16.dp)) + } + + GroupedRow( + title = stringResource(R.string.settings_special_dates_enable), + summary = stringResource(R.string.settings_special_dates_enable_hint), + position = Position.Alone, + trailing = { + Switch( + checked = state.enabled, + onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true }, + ) + }, + onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() }, + ) + + if (state.enabled) { + // Per-type card: the toggle, and while on, its title format and its + // calendar-wide reminder. + SpecialDateType.entries.forEachIndexed { index, type -> + Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp)) + val on = type in state.types + GroupedRow( + title = stringResource(specialDateTypeLabel(type)), + position = if (on) Position.Top else Position.Alone, + trailing = { + Switch( + checked = on, + onCheckedChange = { want -> + if (want) viewModel.setSpecialDateTypeEnabled(type, true) + else confirmDisableType = type + }, + ) + }, + onClick = { + if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true) + }, + ) + if (on) { + GroupedRow( + title = stringResource(R.string.settings_special_dates_template), + summary = state.titleTemplates[type].orEmpty(), + position = Position.Middle, + onClick = { editTemplate = type }, + ) + GroupedRow( + title = stringResource(R.string.settings_special_dates_reminders), + summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])), + position = Position.Bottom, + onClick = { reminderPickerType = type }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_special_dates_show_year), + summary = stringResource(R.string.settings_special_dates_show_year_hint), + position = Position.Top, + trailing = { + Switch( + checked = state.showYear, + onCheckedChange = viewModel::setSpecialDatesShowYear, + ) + }, + onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) }, + ) + GroupedRow( + title = stringResource(R.string.settings_special_dates_sync_now), + summary = specialDatesLastRunLabel(context, state.lastRun), + position = Position.Bottom, + onClick = viewModel::syncSpecialDatesNow, + ) + + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.settings_special_dates_calendar_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + if (confirmDisableAll) { + SpecialDatesDisableDialog( + message = stringResource(R.string.settings_special_dates_disable_all_message), + onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false }, + onDismiss = { confirmDisableAll = false }, + ) + } + confirmDisableType?.let { type -> + SpecialDatesDisableDialog( + message = stringResource( + R.string.settings_special_dates_disable_type_message, + stringResource(specialDateTypeLabel(type)), + ), + onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null }, + onDismiss = { confirmDisableType = null }, + ) + } + editTemplate?.let { type -> + SpecialDatesTemplateDialog( + initial = state.titleTemplates[type].orEmpty(), + onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null }, + onDismiss = { editTemplate = null }, + ) + } + reminderPickerType?.let { type -> + ReminderDefaultPicker( + title = stringResource(R.string.settings_special_dates_reminders), + presets = ALLDAY_REMINDER_PRESETS, + selected = state.reminderChoices[type] ?: ReminderOverride.None, + // Managed calendars own their reminders outright — no "inherit global". + allowInherit = false, + onSelect = { viewModel.setSpecialDatesReminders(type, it) }, + onDismiss = { reminderPickerType = null }, + // Special dates are all-day events, so their reminders fire at the + // one global all-day hour like any other all-day reminder. + leadTimeSummary = allDayFiringTimeSummary(settings.allDayReminderTimeMinutes), + ) + } +} + +/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */ +private fun specialDatesReminderMinutes(choice: ReminderOverride?): List = + (choice as? ReminderOverride.Minutes)?.minutes.orEmpty() + +@Composable +private fun SpecialDatesDisableDialog( + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_special_dates_disable_title)) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.settings_special_dates_disable_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dialog_cancel)) + } + }, + ) +} + +@Composable +private fun SpecialDatesTemplateDialog( + initial: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by rememberSaveable { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_special_dates_template)) }, + text = { + Column { + // The app's borderless input over a tonal surface (the dialog + // convention — see DialogControls), not Material's outlined field. + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(12.dp), + ) { + InlineTextField( + value = text, + onValueChange = { text = it }, + placeholder = stringResource(R.string.settings_special_dates_template), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.settings_special_dates_template_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(text) }, + enabled = text.isNotBlank(), + ) { Text(stringResource(R.string.dialog_save)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dialog_cancel)) + } + }, + ) +} + +private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) { + SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday + SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary + SpecialDateType.Custom -> R.string.settings_special_dates_type_custom +} + +@Composable +private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String = + if (lastRun <= 0L) { + stringResource(R.string.settings_special_dates_never_synced) + } else { + stringResource( + R.string.settings_special_dates_last_synced, + android.text.format.DateUtils.getRelativeTimeSpanString( + lastRun, + System.currentTimeMillis(), + android.text.format.DateUtils.MINUTE_IN_MILLIS, + ).toString(), + ) + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt new file mode 100644 index 0000000..5b72f3a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -0,0 +1,428 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import android.text.format.DateFormat +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref +import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel +import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay +import de.jeanlucmakiola.calendula.ui.common.icon +import de.jeanlucmakiola.calendula.ui.common.labelRes +import de.jeanlucmakiola.calendula.ui.month.labelRes +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.ReorderableColumn +import de.jeanlucmakiola.floret.components.ReorderableRowHeight +import de.jeanlucmakiola.floret.locale.currentLocale +import kotlinx.datetime.DayOfWeek +import java.time.format.TextStyle as JavaTextStyle + +/** + * Views: everything that changes how a calendar view reads, grouped by the view + * it belongs to, plus the two cross-view ordering lists (#24, #69). + * + * The first group holds what applies everywhere (default view, week start, time + * format); the rest are per-view. Anything that styles the *app* rather than a + * view (theme, fonts) is in [AppearanceScreen]; the widget's own copies of the + * agenda settings are in [WidgetsScreen]. + * + * The quick-switch cycle and the navigation-drawer list are two independent + * orders — a view disabled in the quick-switch cycle is still reachable from the + * drawer, which always lists every view. The switch needs at least two targets, + * so the last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off. + */ +@Composable +internal fun ViewsScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + var showMonthStyle by remember { mutableStateOf(false) } + var showDefaultView by remember { mutableStateOf(false) } + var showWeekStart by remember { mutableStateOf(false) } + var showTimeFormat by remember { mutableStateOf(false) } + var showPastEvents by remember { mutableStateOf(false) } + var showAgendaScreenRange by remember { mutableStateOf(false) } + + CollapsingScaffold( + title = stringResource(R.string.settings_section_views), + onBack = onBack, + predictiveBack = true, + ) { + val config = state.quickSwitchConfig + + // What holds for every view, above the per-view groups. + SectionHeader(stringResource(R.string.settings_views_all_header)) + GroupedRow( + title = stringResource(R.string.settings_default_view), + summary = stringResource(state.defaultView.labelRes), + position = Position.Top, + onClick = { showDefaultView = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_week_start), + summary = weekStartLabel(state.weekStart), + position = Position.Middle, + onClick = { showWeekStart = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_time_format), + summary = timeFormatLabel(state.timeFormat), + position = Position.Middle, + onClick = { showTimeFormat = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_today_toolbar), + summary = stringResource(R.string.settings_today_toolbar_summary), + position = Position.Middle, + trailing = { + Switch( + checked = state.todayButtonInToolbar, + onCheckedChange = viewModel::setTodayButtonInToolbar, + ) + }, + onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) }, + ) + GroupedRow( + title = stringResource(R.string.settings_dim_completed), + summary = stringResource(R.string.settings_dim_completed_summary), + position = Position.Bottom, + trailing = { + Switch( + checked = state.dimCompletedEvents, + onCheckedChange = viewModel::setDimCompletedEvents, + ) + }, + onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) }, + ) + + Spacer(Modifier.height(8.dp)) + SectionHeader(stringResource(R.string.settings_month_header)) + GroupedRow( + title = stringResource(R.string.settings_month_view_style), + summary = stringResource(state.monthViewStyle.labelRes), + position = Position.Top, + onClick = { showMonthStyle = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_week_numbers), + summary = stringResource(R.string.settings_week_numbers_summary), + position = Position.Bottom, + trailing = { + Switch( + checked = state.showWeekNumbers, + onCheckedChange = viewModel::setShowWeekNumbers, + ) + }, + onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) }, + ) + + Spacer(Modifier.height(8.dp)) + SectionHeader(stringResource(R.string.settings_week_day_header)) + GroupedRow( + title = stringResource(R.string.settings_hour_lines), + summary = stringResource(R.string.settings_hour_lines_summary), + position = Position.Alone, + trailing = { + Switch( + checked = state.showHourLines, + onCheckedChange = viewModel::setShowHourLines, + ) + }, + onClick = { viewModel.setShowHourLines(!state.showHourLines) }, + ) + + Spacer(Modifier.height(8.dp)) + SectionHeader(stringResource(R.string.settings_agenda_header)) + GroupedRow( + title = stringResource(R.string.settings_agenda_range), + summary = agendaRangeLabel(state.agendaScreenRange), + position = Position.Top, + onClick = { showAgendaScreenRange = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_past_events), + summary = pastEventDisplayLabel(state.pastEventDisplay), + position = Position.Middle, + onClick = { showPastEvents = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_agenda_show_today), + summary = stringResource(R.string.settings_agenda_show_today_hint), + position = Position.Middle, + trailing = { + Switch( + checked = state.agendaShowToday, + onCheckedChange = viewModel::setAgendaShowToday, + ) + }, + onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) }, + ) + GroupedRow( + title = stringResource(R.string.settings_agenda_range_bar), + summary = stringResource(R.string.settings_agenda_range_bar_hint), + position = Position.Bottom, + trailing = { + Switch( + checked = state.agendaShowRangeBar, + onCheckedChange = viewModel::setAgendaShowRangeBar, + ) + }, + onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) }, + ) + + Spacer(Modifier.height(24.dp)) + SectionHeader(stringResource(R.string.settings_quick_switch_header)) + SettingsHint(stringResource(R.string.settings_quick_switch_hint)) + Spacer(Modifier.height(8.dp)) + // Turning a view off is blocked once only the minimum remain enabled. + val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED + ReorderableColumn( + items = config.order, + keyOf = { it }, + onReorder = { viewModel.setQuickSwitchOrder(it) }, + ) { view, position, dragHandle, isDragging -> + val checked = view in config.enabled + ViewRow( + view = view, + position = position, + isDragging = isDragging, + dragHandle = dragHandle, + dimmed = !checked, + trailing = { + Switch( + checked = checked, + // Keep the last two on: with fewer, the pill can't switch. + enabled = !checked || canDisable, + onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) }, + ) + }, + ) + } + + Spacer(Modifier.height(24.dp)) + SectionHeader(stringResource(R.string.settings_drawer_order_header)) + SettingsHint(stringResource(R.string.settings_drawer_order_hint)) + Spacer(Modifier.height(8.dp)) + ReorderableColumn( + items = state.drawerViewOrder, + keyOf = { it }, + onReorder = { viewModel.setDrawerViewOrder(it) }, + ) { view, position, dragHandle, isDragging -> + ViewRow( + view = view, + position = position, + isDragging = isDragging, + dragHandle = dragHandle, + ) + } + } + + if (showMonthStyle) { + MonthViewStylePicker( + selected = state.monthViewStyle, + // The preview is a real grid, so it uses the real week start too. + weekStart = state.weekStart.resolveFirstDay(currentLocale()), + onSelect = viewModel::setMonthViewStyle, + onDismiss = { showMonthStyle = false }, + ) + } + if (showDefaultView) { + OptionPicker( + title = stringResource(R.string.settings_default_view), + header = { PickerDescription(stringResource(R.string.settings_default_view_hint)) }, + predictiveBack = true, + options = IMPLEMENTED_VIEWS, + selected = state.defaultView, + label = { stringResource(it.labelRes) }, + // The same icon each view carries in the drawer and the switcher + // pill, so the row is matched to the view by eye, not by reading. + leading = { + Icon( + imageVector = it.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onSelect = viewModel::setDefaultView, + onDismiss = { showDefaultView = false }, + ) + } + if (showWeekStart) { + WeekStartPicker( + selected = state.weekStart, + // The preview is a real grid, so it uses the user's own month style. + monthStyle = state.monthViewStyle, + options = WEEK_START_OPTIONS, + onSelect = viewModel::setWeekStart, + onDismiss = { showWeekStart = false }, + ) + } + if (showTimeFormat) { + val locale = currentLocale() + val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current) + OptionPicker( + title = stringResource(R.string.settings_time_format), + header = { PickerDescription(stringResource(R.string.settings_time_format_hint)) }, + predictiveBack = true, + options = TimeFormatPref.entries, + selected = state.timeFormat, + label = { timeFormatLabel(it) }, + // Each option renders the same sample time the way it would write + // it — an afternoon one, since that is where 12h and 24h diverge. + // "Automatic" additionally says which of the two it resolves to now. + summary = { pref -> + val sample = formatTimeOfDay( + hour = SAMPLE_HOUR, + minute = SAMPLE_MINUTE, + is24Hour = when (pref) { + TimeFormatPref.AUTO -> systemIs24Hour + TimeFormatPref.TWELVE_HOUR -> false + TimeFormatPref.TWENTY_FOUR_HOUR -> true + }, + locale = locale, + ) + if (pref == TimeFormatPref.AUTO) { + stringResource(R.string.settings_time_format_auto_summary, sample) + } else { + sample + } + }, + onSelect = viewModel::setTimeFormat, + onDismiss = { showTimeFormat = false }, + ) + } + if (showPastEvents) { + PastEventsPicker( + selected = state.pastEventDisplay, + onSelect = viewModel::setPastEventDisplay, + onDismiss = { showPastEvents = false }, + ) + } + if (showAgendaScreenRange) { + AgendaRangePicker( + title = stringResource(R.string.settings_agenda_range), + description = stringResource(R.string.settings_agenda_range_hint), + selected = state.agendaScreenRange, + // Resolving each option to real dates needs the same week start the + // agenda itself windows by. + weekStart = state.weekStart.resolveFirstDay(currentLocale()), + onSelect = viewModel::setAgendaScreenRange, + onDismiss = { showAgendaScreenRange = false }, + ) + } +} + +/** Sample time the format options render: afternoon, where 12h and 24h differ. */ +private const val SAMPLE_HOUR = 14 +private const val SAMPLE_MINUTE = 30 + +/** One reorderable view row: the view's icon and name, an optional [trailing] + * control, and a drag handle carrying the [dragHandle] gesture modifier. */ +@Composable +private fun ViewRow( + view: CalendarView, + position: Position, + isDragging: Boolean, + dragHandle: Modifier, + dimmed: Boolean = false, + trailing: @Composable (() -> Unit)? = null, +) { + GroupedRow( + title = stringResource(view.labelRes), + position = position, + dimmed = dimmed, + minHeight = ReorderableRowHeight, + // The reorderable column owns the inter-row spacing (uniform pitch). + gapBelow = false, + container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null, + leading = { + Icon( + imageVector = view.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = { + Row(verticalAlignment = Alignment.CenterVertically) { + trailing?.invoke() + if (trailing != null) Spacer(Modifier.width(8.dp)) + Box( + modifier = dragHandle.size(48.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = stringResource(R.string.reorder_drag_handle), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + ) +} + +/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */ +private val WEEK_START_OPTIONS: List = + listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) } + +@Composable +internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) { + WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto) + // Localised full weekday name, so any of the seven days reads naturally + // without a per-day string resource. + is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1) + .getDisplayName(JavaTextStyle.FULL, currentLocale()) +} + +@Composable +private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource( + when (pref) { + TimeFormatPref.AUTO -> R.string.settings_time_format_auto + TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h + TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h + }, +) + +@Composable +private fun pastEventDisplayLabel(mode: PastEventDisplay): String = + stringResource(pastEventDisplayLabelRes(mode)) + +/** Shared with [PastEventsPicker], which needs the id rather than the string. */ +@StringRes +internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode) { + PastEventDisplay.SHOW -> R.string.settings_past_events_show + PastEventDisplay.DIM -> R.string.settings_past_events_dim + PastEventDisplay.HIDE -> R.string.settings_past_events_hide +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt new file mode 100644 index 0000000..8a04727 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt @@ -0,0 +1,121 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import de.jeanlucmakiola.floret.locale.currentLocale + +/** + * The week-start chooser, with the grid it rearranges shown above it. + * + * Which column a week opens on is the most visual setting in the app, and the + * one hardest to hold in your head from a weekday name alone. The preview is + * the same [MonthStylePreview] the Month style picker uses, drawn in the user's + * *own* month style, so what moves is exactly what will move on the real + * screen. It is short here (the option list runs to eight rows), which the + * preview supports natively by scaling. + * + * "Follow the system" carries the day it currently resolves to as its summary — + * otherwise the one automatic option is the only one whose effect is invisible. + * + * A preview picker, so selecting applies at once and keeps the picker open; + * back exits (see `FullScreenPicker`). + */ +@Composable +internal fun WeekStartPicker( + selected: WeekStartPref, + monthStyle: MonthViewStyle, + options: List, + onSelect: (WeekStartPref) -> Unit, + onDismiss: () -> Unit, +) { + val locale = currentLocale() + val reduceMotion = rememberReduceMotion() + val resolved = selected.resolveFirstDay(locale) + FullScreenPicker( + title = stringResource(R.string.settings_week_start), + onDismiss = onDismiss, + predictiveBack = true, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .height(PREVIEW_HEIGHT), + contentAlignment = Alignment.Center, + ) { + Crossfade( + targetState = resolved, + animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), + label = "week-start-preview", + ) { day -> + Box( + modifier = Modifier + .clip(PREVIEW_SHAPE) + .background(MaterialTheme.colorScheme.surface) + .clipToBounds(), + ) { + MonthStylePreview( + style = monthStyle, + weekStart = day, + height = PREVIEW_HEIGHT, + ) + } + } + } + PickerDescription(stringResource(R.string.settings_week_start_hint)) + options.forEachIndexed { index, option -> + val isSelected = option == selected + GroupedRow( + title = weekStartLabel(option), + // Only the automatic option needs a second line: it is the one + // whose effect the label doesn't name. + summary = if (option == WeekStartPref.Auto) { + stringResource( + R.string.settings_week_start_auto_summary, + weekStartLabel(WeekStartPref.Day(option.resolveFirstDay(locale))), + ) + } else { + null + }, + position = positionOf(index, options.size), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { onSelect(option) }, + ) + } + } +} + +/** Short enough to leave the first options on screen under it. */ +private val PREVIEW_HEIGHT = 200.dp +private val PREVIEW_SHAPE = RoundedCornerShape(12.dp) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt new file mode 100644 index 0000000..e0cf871 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -0,0 +1,160 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import android.app.StatusBarManager +import android.content.ComponentName +import android.content.Context +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.qs.NewEventTileService +import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel +import de.jeanlucmakiola.calendula.widget.WidgetSize +import de.jeanlucmakiola.calendula.widget.agenda.metricsFor +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.locale.currentLocale +import kotlin.math.roundToInt + +/** + * Widgets & tiles (#69): the home-screen widgets' own settings and the Quick + * Settings tile shortcut — the app's system surfaces, collected in one place. + * + * The agenda widget keeps its own range and text size, deliberately separate + * from the Agenda *screen*'s range in [ViewsScreen]: a widget is glanced at, a + * screen is browsed, and having the two rows sit next to each other (as they did + * before) made them easy to mistake for one another. + */ +@Composable +internal fun WidgetsScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + var showAgendaWidgetRange by remember { mutableStateOf(false) } + var showWidgetSize by remember { mutableStateOf(false) } + + CollapsingScaffold( + title = stringResource(R.string.settings_section_widgets), + onBack = onBack, + predictiveBack = true, + ) { + SettingsHint(stringResource(R.string.settings_widgets_hint)) + Spacer(Modifier.height(8.dp)) + + GroupedRow( + title = stringResource(R.string.settings_agenda_widget_range), + summary = agendaRangeLabel(state.agendaWidgetRange), + position = Position.Top, + onClick = { showAgendaWidgetRange = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_widget_size), + summary = widgetSizeLabel(state.widgetSize), + position = Position.Bottom, + onClick = { showWidgetSize = true }, + ) + + // One-tap add of the "New event" Quick Settings tile. The system prompt + // is API 33+; on older versions the tile is still addable manually from + // the QS editor, so the row simply doesn't appear here. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Spacer(Modifier.height(24.dp)) + QuickSettingsTileRow() + } + } + + if (showAgendaWidgetRange) { + AgendaRangePicker( + title = stringResource(R.string.settings_agenda_widget_range), + description = stringResource(R.string.settings_agenda_widget_range_hint), + selected = state.agendaWidgetRange, + weekStart = state.weekStart.resolveFirstDay(currentLocale()), + onSelect = viewModel::setAgendaWidgetRange, + onDismiss = { showAgendaWidgetRange = false }, + ) + } + if (showWidgetSize) { + OptionPicker( + title = stringResource(R.string.settings_widget_size), + header = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) }, + predictiveBack = true, + options = WidgetSize.entries, + selected = state.widgetSize, + label = { widgetSizeLabel(it) }, + // A size name says nothing about what you get; the text scale it + // draws at does. No live preview here on purpose: the widget is + // Glance/RemoteViews, so a Compose mock-up would be a second + // implementation free to drift from the real thing. + summary = { widgetSizeSummary(it) }, + onSelect = viewModel::setWidgetSize, + onDismiss = { showWidgetSize = false }, + ) + } +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +private fun QuickSettingsTileRow() { + val context = LocalContext.current + GroupedRow( + title = stringResource(R.string.settings_qs_tile), + summary = stringResource(R.string.settings_qs_tile_hint), + position = Position.Alone, + onClick = { requestAddQsTile(context) }, + ) +} + +/** + * Ask the system to add the "New event" Quick Settings tile (API 33+). The OS + * shows its own confirmation dialog and handles the already-added case, so no + * result handling is needed here. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private fun requestAddQsTile(context: Context) { + val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return + statusBar.requestAddTileService( + ComponentName(context, NewEventTileService::class.java), + context.getString(R.string.qs_tile_new_event_label), + Icon.createWithResource(context, R.drawable.ic_qs_new_event), + context.mainExecutor, + ) { /* result code unused — the system surfaces its own feedback */ } +} + +/** + * What a size step actually does, as a percentage of the smallest one's event + * text. Derived from the widget's own [metricsFor] table rather than written + * out here, so a retuned step can't leave this line claiming the old number. + */ +@Composable +private fun widgetSizeSummary(size: WidgetSize): String { + val base = metricsFor(WidgetSize.SMALL).eventTitle.value + val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt() + return stringResource(R.string.settings_widget_size_summary, percent) +} + +@Composable +private fun widgetSizeLabel(size: WidgetSize): String = stringResource( + when (size) { + WidgetSize.SMALL -> R.string.settings_widget_size_small + WidgetSize.MEDIUM -> R.string.settings_widget_size_medium + WidgetSize.LARGE -> R.string.settings_widget_size_large + WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large + }, +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10d5521..cedc6ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -155,6 +155,9 @@ Enter a number from 1 to 999 + + Next: %1$s + This rule never repeats Ends Never On a date @@ -198,6 +201,12 @@ Free Private Confidential + + Whatever this calendar normally does + Everyone with access sees the full details + Others see only that you\'re busy + Marked confidential; what that means is up to the calendar account Organizer Optional Resource @@ -315,6 +324,9 @@ System Light Dark + Whether the app is light or dark. The choice applies immediately. + + Currently %1$s Default view Dynamic colour Requires Android 12 or newer @@ -329,8 +341,14 @@ Choose file… Custom font Couldn\'t read that file as a font + + Thursday, 14 May + + Team review at 10:00, then lunch with Robin at the café on Marktplatz. Week starts on Automatic + + Currently %1$s Week numbers Show calendar-week numbers in month view Today button in toolbar @@ -341,6 +359,8 @@ Automatic 12-hour (2:00 PM) 24-hour (14:00) + + Following the system: %1$s Hour lines Show a separator line at each hour in week and day view Dim completed events @@ -349,6 +369,10 @@ Show Dim Hide + + Team review + Lunch with Robin + Choir practice Agenda Agenda range How far ahead the Agenda screen lists events. @@ -361,6 +385,8 @@ Medium Large Extra large + + Event text %1$d%% Always show today Keep today at the top of the agenda and its widget, even once nothing is left today. Range bar @@ -411,6 +437,8 @@ All-day events All-day reminder time Reminders for all-day events fire at %1$s + + Notifies at %1$s None Use default reminder Amount @@ -430,12 +458,39 @@ System default Help translate Add or improve a language on Weblate + + Look & behaviour + Data + App + + About + - Theme, default view, week start - Month layout, quick-switch button, menu order - Default fields for new events - Event reminders + Theme, colours, fonts + Default view, layout, order + Default fields and behaviour + Reminders and delivery Contact birthdays & anniversaries + Agenda widget, Quick Settings tile + Export, import, automatic backup + + + Widgets & tiles + Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen. + + + Backup & restore + + + All views + Week & day + + + The view Calendula opens on when you start it. + The day every week begins on, in all views and widgets. + How times are written throughout the app. Automatic follows your system setting. + What the agenda does with events that have already ended. + Take the app\'s colours from your wallpaper. Contact special dates diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrencesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrencesTest.kt new file mode 100644 index 0000000..50a6729 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrencesTest.kt @@ -0,0 +1,178 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class RecurrenceOccurrencesTest { + + private fun date(year: Int, month: Int, day: Int) = LocalDate(year, month, day) + + @Test + fun `daily rule starts at the event's own date`() { + val occurrences = SimpleRecurrence(RecurrenceFreq.Daily) + .upcomingOccurrences(date(2026, 7, 30), limit = 3) + + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 7, 31), + date(2026, 8, 1), + ).inOrder() + } + + @Test + fun `interval multiplies the period`() { + val occurrences = SimpleRecurrence(RecurrenceFreq.Daily, interval = 3) + .upcomingOccurrences(date(2026, 7, 30), limit = 3) + + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 8, 2), + date(2026, 8, 5), + ).inOrder() + } + + @Test + fun `weekly without weekday picks repeats the start's weekday`() { + // 30 Jul 2026 is a Thursday. + val occurrences = SimpleRecurrence(RecurrenceFreq.Weekly) + .upcomingOccurrences(date(2026, 7, 30), limit = 3) + + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 8, 6), + date(2026, 8, 13), + ).inOrder() + } + + @Test + fun `weekly with picks fires on every chosen day, in weekday order`() { + val occurrences = SimpleRecurrence( + RecurrenceFreq.Weekly, + byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY), + ).upcomingOccurrences(date(2026, 7, 30), limit = 4) + + // Thursday start: the Monday of that week is already past, so the first + // hit is Friday 31 Jul, then Mon/Fri of the following weeks. + assertThat(occurrences).containsExactly( + date(2026, 7, 31), + date(2026, 8, 3), + date(2026, 8, 7), + date(2026, 8, 10), + ).inOrder() + } + + @Test + fun `weekly interval skips whole weeks, counted from Monday`() { + val occurrences = SimpleRecurrence( + RecurrenceFreq.Weekly, + interval = 2, + byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.THURSDAY), + ).upcomingOccurrences(date(2026, 7, 30), limit = 3) + + // Start week (27 Jul–2 Aug) contributes only its Thursday; the next block + // is two weeks on, so 10 Aug and 13 Aug — not 3 Aug. + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 8, 10), + date(2026, 8, 13), + ).inOrder() + } + + @Test + fun `monthly skips months without the start day rather than clamping`() { + val occurrences = SimpleRecurrence(RecurrenceFreq.Monthly) + .upcomingOccurrences(date(2026, 1, 31), limit = 4) + + // February, April and June have no 31st — the rule passes them by. + assertThat(occurrences).containsExactly( + date(2026, 1, 31), + date(2026, 3, 31), + date(2026, 5, 31), + date(2026, 7, 31), + ).inOrder() + } + + @Test + fun `yearly on 29 February only lands in leap years`() { + val occurrences = SimpleRecurrence(RecurrenceFreq.Yearly) + .upcomingOccurrences(date(2024, 2, 29), limit = 3) + + assertThat(occurrences).containsExactly( + date(2024, 2, 29), + date(2028, 2, 29), + date(2032, 2, 29), + ).inOrder() + } + + @Test + fun `count limits the series and skipped periods don't consume one`() { + val occurrences = SimpleRecurrence( + RecurrenceFreq.Monthly, + end = RecurrenceEnd.Count(3), + ).upcomingOccurrences(date(2026, 1, 31), limit = 10) + + assertThat(occurrences).containsExactly( + date(2026, 1, 31), + date(2026, 3, 31), + date(2026, 5, 31), + ).inOrder() + } + + @Test + fun `until is inclusive of its own date`() { + val occurrences = SimpleRecurrence( + RecurrenceFreq.Daily, + end = RecurrenceEnd.Until(date(2026, 8, 1)), + ).upcomingOccurrences(date(2026, 7, 30), limit = 10) + + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 7, 31), + date(2026, 8, 1), + ).inOrder() + } + + @Test + fun `an until before the start yields nothing instead of spinning`() { + val occurrences = SimpleRecurrence( + RecurrenceFreq.Daily, + end = RecurrenceEnd.Until(date(2026, 7, 1)), + ).upcomingOccurrences(date(2026, 7, 30), limit = 3) + + assertThat(occurrences).isEmpty() + } + + @Test + fun `a run inside one year needs no year, one that leaves it does`() { + val start = date(2026, 7, 30) + val withinYear = SimpleRecurrence(RecurrenceFreq.Daily).upcomingOccurrences(start, limit = 3) + assertThat(occurrencesSpanYears(withinYear, start)).isFalse() + + // Yearly: same day and month every time, so the year is the only thing + // telling the three dates apart. + val yearly = SimpleRecurrence(RecurrenceFreq.Yearly).upcomingOccurrences(start, limit = 3) + assertThat(occurrencesSpanYears(yearly, start)).isTrue() + + // Monthly rolling past December. + val newYearStart = date(2026, 11, 30) + val overflowing = SimpleRecurrence(RecurrenceFreq.Monthly) + .upcomingOccurrences(newYearStart, limit = 3) + assertThat(overflowing).containsExactly( + date(2026, 11, 30), + date(2026, 12, 30), + date(2027, 1, 30), + ).inOrder() + assertThat(occurrencesSpanYears(overflowing, newYearStart)).isTrue() + } + + @Test + fun `limit and count are both respected, whichever is smaller`() { + val rule = SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Count(2)) + + assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 5)).hasSize(2) + assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 1)).hasSize(1) + assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 0)).isEmpty() + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 41c4a17..a3e91fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -64,7 +64,9 @@ flowchart TD - **`ui/`** — one package per screen, each with Screen + ViewModel + UiState. Shared pieces in `ui/common/` (OptionCard — the app's only sanctioned selection-dialog style —, recurrence humanizer, FAB column, - drawer, transitions). + drawer, transitions). `ui/settings/` is the exception to "one file per + screen": one `SettingsViewModel` feeds a hub (`SettingsScreen.kt`) plus a + sub-screen per category, each in its own `*Settings.kt`. ## Navigation diff --git a/floret-kit b/floret-kit index b4d3ead..ed1d3ca 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit b4d3ead8f0fe1e042c1cd7f350ff7132f22189b3 +Subproject commit ed1d3ca5e8ff0d63e2ed9d7ece25bcfbcd3be02d