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