From 08fd3467add5d39891cc3859826f57dbbe34840e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 19:51:19 +0200 Subject: [PATCH] Sort settings into the places you'd look for them (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appearance had become the drawer everything got tossed into: 18 rows, half of which weren't appearance at all. Views held three. Backup hid inside the calendar manager, where nobody goes looking for "keep a copy of my calendars". The hub now reads as three labelled groups — look & behaviour, data, app — and everything sits under the heading it belongs to: - Appearance keeps theme/colour, the two fonts, and the app name (18 → 6). - Views takes the per-view settings, grouped by the view they change: all views, month, week & day, agenda — then the two ordering lists (3 → 17). - Widgets & tiles is new: the agenda widget's own range and size, plus the Quick Settings tile. "Agenda range" no longer sits one row above "Agenda widget range" pretending to be the same setting. - Backup & restore is new: export, restore and automatic backup, lifted out of the calendar manager, which keeps a pointer row to it. Same screen logic, same either/or fallback when there's nothing exportable; it shares CalendarsViewModel and is hoisted in CalendarHost like the manager, since both are driven by the calendar list rather than by preferences. Descriptions follow one rule now: a toggle says what happens when it's on (Dynamic colour finally does), and a picker row shows its value, with the explanation in the picker itself when the value alone says nothing — the shape widget size and month style already used. Four picker descriptions added, no string keys renamed or removed, so the translations stay consistent. SettingsScreen.kt is split one file per sub-screen (2108 → 409 lines); the 2000-line single file was why this drifted in the first place. Co-Authored-By: Claude Opus 5 --- .../calendula/ui/CalendarHost.kt | 36 +- .../calendula/ui/calendars/BackupScreen.kt | 390 ++++ .../calendula/ui/calendars/CalendarsScreen.kt | 377 +--- .../ui/settings/AppearanceSettings.kt | 484 +++++ .../ui/settings/EventFormSettings.kt | 107 + .../ui/settings/NotificationSettings.kt | 415 ++++ .../calendula/ui/settings/SettingsCommon.kt | 104 + .../calendula/ui/settings/SettingsScreen.kt | 1825 +---------------- .../ui/settings/SpecialDatesSettings.kt | 321 +++ .../calendula/ui/settings/ViewsSettings.kt | 390 ++++ .../calendula/ui/settings/WidgetSettings.kt | 138 ++ app/src/main/res/values/strings.xml | 33 +- docs/ARCHITECTURE.md | 4 +- 13 files changed, 2502 insertions(+), 2122 deletions(-) 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/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/WidgetSettings.kt 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/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..ff9b8be 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,10 +93,6 @@ 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 @@ -120,39 +100,30 @@ import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow 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 +161,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 +177,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 +199,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 +251,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 +344,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,7 +729,7 @@ private fun CalendarGroupMenu( @Composable -private fun SectionHeader(text: String) { +internal fun SectionHeader(text: String) { Text( text = text, style = MaterialTheme.typography.labelLarge, @@ -968,7 +739,7 @@ private fun SectionHeader(text: String) { } @Composable -private fun HintText(text: String) { +internal fun HintText(text: String) { Text( text = text, style = MaterialTheme.typography.bodySmall, @@ -977,94 +748,6 @@ private fun HintText(text: String) { ) } -/** 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/settings/AppearanceSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt new file mode 100644 index 0000000..62bf5c3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt @@ -0,0 +1,484 @@ +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.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.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.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) { + 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 }, + ) + } +} + +@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. + */ +@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, + ) +} + +/** + * 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..f468ec9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -0,0 +1,415 @@ +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 android.text.format.DateFormat +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 +import java.util.Calendar + +/** + * 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, + 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 }, + ) + } +} + +/** 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) + } + +/** 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) +} + +/** + * 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/SettingsCommon.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt new file mode 100644 index 0000000..90572c6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt @@ -0,0 +1,104 @@ +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.reminderLeadTimeLabel + +/** + * 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. + */ +internal enum class ChipAccent { Neutral, Primary, 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.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. */ +@Composable +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), + ) +} + +/** 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 = 16.dp, 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) 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..89776d8 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.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.calendula.ui.common.rememberCalendarSlideSpec 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.crash.CrashReportDialog +import de.jeanlucmakiola.floret.crash.CrashReporter +import de.jeanlucmakiola.floret.crash.openIssueTracker +import de.jeanlucmakiola.floret.crash.submitCrashReport +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,11 +163,16 @@ 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() } 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. + SectionHeader(stringResource(R.string.settings_group_look)) GroupedRow( title = stringResource(R.string.settings_section_appearance), summary = stringResource(R.string.settings_appearance_subtitle), @@ -277,14 +197,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, ) @@ -295,13 +218,27 @@ private fun SettingsHub( leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Tertiary) }, 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.Tertiary) }, + 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.Neutral) }, + 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) AppVersionText() @@ -343,24 +280,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 @@ -408,6 +327,10 @@ 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 @@ -484,1625 +407,3 @@ private fun AppLogo() { ) } } - -// --------------------------------------------------------------------------- -// 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 }, - ) - } -} - -/** - * 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. - */ -@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) 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..81e180d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt @@ -0,0 +1,321 @@ +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() + 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, +) { + 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..9abe628 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -0,0 +1,390 @@ +package de.jeanlucmakiola.calendula.ui.settings + +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.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.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) }, + onSelect = viewModel::setDefaultView, + onDismiss = { showDefaultView = false }, + ) + } + if (showWeekStart) { + OptionPicker( + title = stringResource(R.string.settings_week_start), + header = { PickerDescription(stringResource(R.string.settings_week_start_hint)) }, + predictiveBack = true, + options = WEEK_START_OPTIONS, + selected = state.weekStart, + label = { weekStartLabel(it) }, + onSelect = viewModel::setWeekStart, + onDismiss = { showWeekStart = false }, + ) + } + if (showTimeFormat) { + 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) }, + onSelect = viewModel::setTimeFormat, + onDismiss = { showTimeFormat = false }, + ) + } + if (showPastEvents) { + OptionPicker( + title = stringResource(R.string.settings_past_events), + header = { PickerDescription(stringResource(R.string.settings_past_events_hint)) }, + predictiveBack = true, + options = PastEventDisplay.entries, + selected = state.pastEventDisplay, + label = { pastEventDisplayLabel(it) }, + 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, + onSelect = viewModel::setAgendaScreenRange, + onDismiss = { showAgendaScreenRange = 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, + ) + } + } + }, + ) +} + +/** 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 + }, +) + +@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 + }, +) 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..8bcfbbc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -0,0 +1,138 @@ +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.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.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position + +/** + * 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, + 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 }, + ) + } +} + +@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 */ } +} + +@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..c9df6fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -430,12 +430,37 @@ System default Help translate Add or improve a language on Weblate + + Look & behaviour + Data + App + - 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/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