Sort settings into the places you'd look for them, and say what each one does (#69) (#107)

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/107
This commit is contained in:
Jean-Luc Makiola
2026-07-30 21:25:28 +02:00
parent 02cf32b537
commit ca2069da0d
24 changed files with 3577 additions and 2166 deletions

View File

@@ -0,0 +1,122 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.plus
/**
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
* (the event's own start date, i.e. DTSTART), for showing a rule as dates
* instead of as words.
*
* A recurrence rule is the one place in the editor where a correct-sounding
* phrase can still mean something the user didn't intend — "every 2 weeks on
* Mon, Fri", "monthly" on the 31st — and the only honest answer is the dates
* themselves. This is a *preview*, deliberately kept to the shapes the picker
* can build; the provider remains the authority on what actually gets stored
* and expanded.
*
* The rules it mirrors, all from RFC 5545 (the spec the platform's recurrence
* engine implements):
* - a monthly or yearly rule **skips** a period the start day doesn't exist in,
* rather than sliding to the month's last day: the 31st recurring monthly
* lands only in 31-day months, 29 February yearly only in leap years.
* - a weekly rule with weekday picks repeats in blocks of `interval` weeks,
* with the week beginning on **Monday** — the RFC's default WKST, and the one
* that applies here because [toRRule] never writes a WKST part. The
* display-only "first day of week" preference deliberately doesn't enter into
* it, or the preview would disagree with the stored rule.
* - [RecurrenceEnd.Count] counts real occurrences (skipped periods don't
* consume one), [RecurrenceEnd.Until] is inclusive of its date.
*
* Returns fewer than [limit] dates when the series ends first, and an empty
* list when the rule yields nothing at all (an UNTIL before the start).
*/
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
if (limit <= 0) return emptyList()
val until = (end as? RecurrenceEnd.Until)?.date
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
val wanted = minOf(limit, maxCount)
if (wanted <= 0) return emptyList()
val result = mutableListOf<LocalDate>()
var period = 0
// Periods that produce nothing (a skipped 31st, a weekday block whose picks
// all fall before the start) must not stall the walk, so the cap is on
// periods examined rather than on dates found.
while (result.size < wanted && period < MAX_PERIODS) {
for (date in occurrencesInPeriod(period, start)) {
if (date < start) continue
if (until != null && date > until) return result
result += date
if (result.size == wanted) return result
}
period++
}
return result
}
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
when (freq) {
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
RecurrenceFreq.Monthly -> {
val month = start.plus(period * interval, DateTimeUnit.MONTH)
// plus() clamps the day into the shorter month; the rule instead
// skips such a period, so a clamped date means "not this month".
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
}
RecurrenceFreq.Yearly ->
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
}
/**
* One weekly block: every picked weekday inside the week that begins
* `period * interval` weeks after the start's own week, in weekday order. With
* no picks the rule simply repeats the start's weekday.
*/
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
DAYS_PER_WEEK
val weekStart = start
.minus(daysIntoWeek, DateTimeUnit.DAY)
.plus(period * interval, DateTimeUnit.WEEK)
return byDays.sortedBy { it.isoDayNumber }.map { day ->
weekStart.plus(
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
DateTimeUnit.DAY,
)
}
}
/**
* Whether a run of [occurrences] starting at [start] leaves its starting year —
* i.e. whether showing them without a year would be ambiguous.
*
* Day and month alone are enough for a rule that stays inside one year, and
* dropping the year keeps the line short. They are actively misleading
* otherwise: a yearly rule reads as the same date three times over, and a
* monthly one rolling past December gives no hint that it has.
*/
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
runCatching { LocalDate(year, month, day) }.getOrNull()
private const val DAYS_PER_WEEK = 7
/**
* How many repetitions to examine before giving up. Generous enough for the
* sparsest rule the picker can build (29 February yearly with a 999-year
* interval is nonsense; 31st monthly needs at most a handful), and bounded so a
* rule whose occurrences all fall outside its own UNTIL can't spin.
*/
private const val MAX_PERIODS = 2_000

View File

@@ -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 },
)
}
}

View File

@@ -93,6 +93,7 @@ fun AgendaScreen(
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -198,6 +199,7 @@ fun AgendaScreen(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.agenda_range_override_hint),
selected = successState?.range ?: AgendaRange.Month,
weekStart = weekStart,
onSelect = viewModel::setRangeOverride,
onDismiss = { showRangePicker = false },
)

View File

@@ -48,8 +48,12 @@ class AgendaViewModel @Inject constructor(
settingsPrefs.agendaShowRangeBar,
) { range, showBar -> AgendaSettings(range, showBar) }
// First day of the week, for the calendar-aligned "this week" range.
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
/**
* First day of the week, for the calendar-aligned "this week" range. Public
* because the range picker resolves each option to real dates, which needs
* the same week start the window is built from.
*/
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
/**
* How to treat events that already ended today (show / dim / hide). A display
@@ -90,7 +94,7 @@ class AgendaViewModel @Inject constructor(
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> =
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
AgendaParams(
anchor = anchor,
range = override ?: settings.range,

View File

@@ -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<CalendarSource>,
onExport: (Uri, Set<Long>?) -> 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

View File

@@ -4,9 +4,6 @@ import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
@@ -26,26 +23,22 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -67,34 +60,25 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.domain.isNotSynced
import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.domain.stateLabels
@@ -109,50 +93,38 @@ import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
// SAF mime filter for the restore picker. `.ics` files reach us under several
// mimes depending on the source app (our own export uses text/calendar; others
// hand them out as octet-stream or text/plain), so accept the common set rather
// than hide valid backups behind an over-tight filter.
private val RESTORE_MIME_TYPES = arrayOf(
"text/calendar",
"application/octet-stream",
"text/plain",
)
/**
* Calendar manager (reached from Settings). Lists the app's own device-only
* calendars with create / rename / recolor / delete (via a full-screen editor),
* and lists synced calendars read-only with a per-account "manage in the source
* app" deep-link — the app never touches a synced calendar's server. A
* full-screen destination; [onBack] pops it.
* app" deep-link — the app never touches a synced calendar's server.
*
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
* screen only points at it, because the two are looked for separately: "which
* calendars do I have" versus "keep a copy of them". A full-screen destination;
* [onBack] pops it.
*/
@Composable
fun CalendarsScreen(
onBack: () -> Unit,
onImport: (android.net.Uri) -> Unit,
onOpenBackup: () -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
// [editorSession] bumps on every open so the editor's field state resets for
@@ -190,14 +162,7 @@ fun CalendarsScreen(
synced = calendars.filterNot { it.isLocal },
error = error,
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
onExportBackup = viewModel::exportBackup,
onImport = onImport,
onConsumeBackupResult = viewModel::consumeBackupResult,
autoBackup = autoBackup,
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
onOpenBackup = onOpenBackup,
onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
@@ -213,14 +178,7 @@ private fun CalendarsList(
synced: List<CalendarSource>,
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
onImport: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit,
autoBackup: AutoBackupUiState,
onSetAutoBackupEnabled: (Boolean) -> Unit,
onSetAutoBackupInterval: (Long) -> Unit,
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
onOpenBackup: () -> Unit,
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
@@ -242,47 +200,6 @@ private fun CalendarsList(
}
}
// SAF "create document" target for the backup file. The picked Uri is handed
// to the VM to stream the .ics into. This launcher exports everything
// eligible (null); the per-calendar selector owns its own launcher.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let { onExportBackup(it, null) } }
var showExportPicker by rememberSaveable { mutableStateOf(false) }
// SAF "open document" picker for restoring events from a .ics file. The
// picked Uri is handed up to the host, which runs it through the same import
// flow as an externally opened .ics (parse, dedup by UID, target picker).
val openBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(onImport) }
// SAF folder picker for the automatic-backup destination; the VM persists the
// write grant so background runs can keep writing to it.
val pickFolder = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(onSetAutoBackupFolder) }
var showInterval by remember { mutableStateOf(false) }
val backupFailedText = stringResource(R.string.calendars_backup_failed)
LaunchedEffect(backupResult) {
when (val r = backupResult) {
is BackupResult.Success -> {
snackbarHostState.showSnackbar(
context.resources.getQuantityString(
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
),
)
onConsumeBackupResult()
}
BackupResult.Failure -> {
snackbarHostState.showSnackbar(backupFailedText)
onConsumeBackupResult()
}
null -> Unit
}
}
CollapsingScaffold(
title = stringResource(R.string.calendars_title),
onBack = onBack,
@@ -335,82 +252,24 @@ private fun CalendarsList(
}
}
// Backup — local calendars have no sync, so a .ics export is their only
// safety net. Offered only when there is something exportable: the user's
// own local calendars (managed special-dates mirrors don't count).
val exportable = local.filter { it.canModifyContents && !it.isManaged }
// Restore/import can target any calendar the import picker would offer
// (local or synced), so its availability is broader than export's.
val canImport = (local + synced).any { it.isEventTarget }
if (exportable.isNotEmpty()) {
// 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))
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) } },
)
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),
title = stringResource(R.string.settings_section_backup),
summary = stringResource(R.string.settings_backup_subtitle),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
leading = { LeadingAvatar(Icons.Default.Backup) },
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenBackup,
)
}
Spacer(Modifier.height(16.dp))
@@ -486,93 +345,6 @@ private fun CalendarsList(
}
}
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
onConfirm = onSetAutoBackupInterval,
onDismiss = { showInterval = false },
)
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = local.filter { it.canModifyContents && !it.isManaged },
onExport = onExportBackup,
onDismiss = { showExportPicker = false },
)
}
}
/**
* Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back
* the picked file with the chosen calendar ids.
*/
@Composable
private fun ExportCalendarPicker(
calendars: List<CalendarSource>,
onExport: (android.net.Uri, Set<Long>?) -> Unit,
onDismiss: () -> Unit,
) {
// Seed once with everything selected and hold it across recomposition and
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
// it would silently reset the user's de-selections whenever the provider
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
// — the data layer intersects the chosen set with the eligible calendars.
var selected by rememberSaveable(
stateSaver = listSaver(
save = { it.toList() },
restore = { it.toSet() },
),
) {
mutableStateOf(calendars.map { it.id }.toSet())
}
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri ->
if (uri != null) {
onExport(uri, selected)
onDismiss()
}
}
FullScreenPicker(
title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss,
) {
HintText(stringResource(R.string.calendars_export_hint))
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Checkbox(
checked = isSelected,
onCheckedChange = { checked ->
selected = if (checked) selected + calendar.id else selected - calendar.id
},
)
},
onClick = {
selected = if (isSelected) selected - calendar.id else selected + calendar.id
},
)
}
Button(
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
enabled = selected.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(stringResource(R.string.calendars_export_action))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -958,113 +730,31 @@ private fun CalendarGroupMenu(
@Composable
private fun SectionHeader(text: String) {
internal fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
// The cards' own edge, so header and group share a left margin.
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
@Composable
private fun HintText(text: String) {
internal fun HintText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
)
}
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
@Composable
private fun rememberFolderName(uriString: String?): String? {
val context = LocalContext.current
return remember(uriString) {
uriString?.let {
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
}
}
}
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
@Composable
private fun backupIntervalLabel(minutes: Long): String {
val duration = when {
minutes % MINUTES_PER_WEEK == 0L ->
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
minutes % MINUTES_PER_DAY == 0L ->
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
minutes % 60L == 0L ->
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
else ->
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
}
return stringResource(R.string.calendars_auto_backup_every, duration)
}
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
@Composable
private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = DateUtils.getRelativeTimeSpanString(
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else {
stringResource(R.string.calendars_auto_backup_status_failed, relative)
}
}
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
@Composable
private fun BackupIntervalDialog(
currentMinutes: Long,
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
val units = stringArrayResource(R.array.backup_interval_units).toList()
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.calendars_auto_backup_interval_min),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = {
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
onDismiss()
}) { Text(stringResource(R.string.reminder_custom_set)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private const val MINUTES_PER_DAY = 1_440L
private const val MINUTES_PER_WEEK = 10_080L
/**
* Pick the app to open for managing a synced calendar's account. The account's

View File

@@ -23,16 +23,26 @@ import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* Reminder-default picker, full-screen and **multi-select**: each [presets]
@@ -48,6 +58,11 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
* row expands an inline number field plus a unit selector to add an arbitrary
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
* back.
*
* [leadTimeSummary] adds a second line to each lead-time row. It exists for the
* all-day pickers, where the lead time alone doesn't say when anything happens:
* the hour comes from the separate "show all-day reminders at" setting, so
* "1 day before" is only half the answer until the row spells out the time.
*/
@Composable
fun ReminderDefaultPicker(
@@ -57,6 +72,7 @@ fun ReminderDefaultPicker(
allowInherit: Boolean,
onSelect: (ReminderOverride) -> Unit,
onDismiss: () -> Unit,
leadTimeSummary: (@Composable (Int) -> String?)? = null,
) {
// Optimistic local state: once the user edits, the chosen override is
// authoritative while the picker is open, so quick successive toggles compose
@@ -132,6 +148,7 @@ fun ReminderDefaultPicker(
val checked = minute in selectedMinutes
GroupedRow(
title = reminderLeadTimeLabel(minute),
summary = leadTimeSummary?.invoke(minute),
position = positionOf(index, rowCount),
selected = checked,
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
@@ -196,14 +213,15 @@ private fun CustomReminderEditor(
)
}
/** A short explanatory paragraph shown under a picker's title, above the rows. */
/** A short explanatory paragraph shown under a picker's title, above the rows,
* on the same left margin as the rows themselves. */
@Composable
internal fun PickerDescription(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
}
@@ -213,12 +231,19 @@ internal fun PickerDescription(text: String) {
* options (today / this week / this month) and the rolling windows (next 7 / 30
* days), with a "Custom" row that expands an inline day-count editor (1365).
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
*
* Every option carries the dates it resolves to today as its summary, computed
* through the same [dayCount] the agenda windows by (hence [weekStart], which
* "This week" depends on). Without it the two groups are near-indistinguishable
* — "This week" and "Next 7 days" name different windows in the same words, and
* only the concrete span tells them apart.
*/
@Composable
fun AgendaRangePicker(
title: String,
description: String,
selected: AgendaRange,
weekStart: DayOfWeek,
onSelect: (AgendaRange) -> Unit,
onDismiss: () -> Unit,
) {
@@ -232,10 +257,21 @@ fun AgendaRangePicker(
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
}
val locale = currentLocale()
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
// The window each option would open right now — the agenda's own end-day
// arithmetic (anchor + dayCount - 1), so the dates match what it will show.
val windowSummary: (AgendaRange) -> String = { range ->
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
agendaRangeWindowSummary(range, today, end, locale)
}
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
val isSelected = option == selected
GroupedRow(
title = agendaRangeLabel(option),
summary = windowSummary(option),
position = position,
selected = isSelected,
trailing = if (isSelected) {
@@ -270,6 +306,9 @@ fun AgendaRangePicker(
} else {
stringResource(R.string.agenda_range_custom)
},
// Only a chosen custom window has dates to show; an unset one has no
// day count yet, so the row stays single-line until it does.
summary = if (customSelected) windowSummary(selected) else null,
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
selected = customSelected,
trailing = if (customSelected) {

View File

@@ -8,6 +8,11 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.toJavaLocalDate
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
@@ -98,6 +103,43 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
}
}
/**
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
* the recurrence picker.
*
* The phrase [recurrenceText] renders says what the rule *is*; this says what
* it *does*, which is the part a monthly rule on the 31st or an every-other-week
* rule with weekday picks gets wrong in people's heads. It expands the rule
* with [upcomingOccurrences], so it inherits that function's RFC reading — and
* its limits: only the shapes the picker itself can build.
*
* A rule that yields nothing at all (an end date before the start) says so
* rather than showing an empty list, since that is a mistake worth catching
* before saving.
*/
@Composable
fun nextOccurrencesText(
rule: SimpleRecurrence,
// Spelled out: in this file, the bare LocalDate is java.time's.
start: kotlinx.datetime.LocalDate,
locale: Locale,
): String {
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
// Years only once they carry information — a yearly rule is otherwise the
// same date repeated, and a monthly one crossing New Year hides that it did.
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
val formatter = localizedDateFormatter(locale, pattern)
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }
return stringResource(
R.string.event_edit_recurrence_next,
ListFormatter.getInstance(locale).format(formatted),
)
}
/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */
private const val NEXT_OCCURRENCE_COUNT = 3
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
private fun rruleDayName(token: String, locale: Locale): String? {
val dow = when (token.takeLast(2).uppercase()) {

View File

@@ -39,6 +39,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
@@ -240,7 +241,12 @@ private fun SectionHeader(text: String) {
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}

View File

@@ -157,6 +157,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -1172,7 +1173,7 @@ private fun EventEditContent(
if (showRecurrencePicker) {
RecurrencePickerDialog(
current = form.rrule,
startDay = form.start.date.dayOfWeek,
startDate = form.start.date,
firstDayOfWeek = firstDayOfWeek,
onSelect = { rrule ->
viewModel.setRecurrence(rrule)
@@ -1340,15 +1341,23 @@ private enum class RecurrenceEndMode { Never, Until, Count }
* and an end condition — only that step needs an OK button. A rule the
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
* user picks something here.
*
* Both steps show the rule as *dates* as well as words: every preset row
* carries the next few occurrences it would produce from [startDate], and the
* custom step repeats that under its live read-out. It is where a rule most
* easily means something other than it sounds like — "monthly" on the 31st
* skips February, an every-other-week rule lands a fortnight from the start's
* own week — and only the dates say so.
*/
@Composable
private fun RecurrencePickerDialog(
current: String?,
startDay: DayOfWeek,
startDate: LocalDate,
firstDayOfWeek: DayOfWeek,
onSelect: (String?) -> Unit,
onDismiss: () -> Unit,
) {
val startDay = startDate.dayOfWeek
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
val isPlainPreset = parsed != null && parsed.interval == 1 &&
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
@@ -1400,16 +1409,20 @@ private fun RecurrencePickerDialog(
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
}
val customResult: String? = if (interval != null && customEnd != null) {
// Kept as the rule object, not just its RRULE text: the read-out renders the
// string, the date list expands the rule, and both must describe the one
// thing OK would save.
val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) {
SimpleRecurrence(
freq = freq,
interval = interval,
end = customEnd,
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
).toRRule()
)
} else {
null
}
val customResult: String? = customRule?.toRRule()
FullScreenPicker(
title = stringResource(R.string.event_detail_recurrence),
@@ -1440,6 +1453,7 @@ private fun RecurrencePickerDialog(
RecurrenceFreq.entries.forEachIndexed { index, entry ->
GroupedRow(
title = stringResource(recurrencePresetLabel(entry)),
summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale),
position = positionOf(index + 1, rowCount),
selected = isPlainPreset && parsed?.freq == entry,
trailing = if (isPlainPreset && parsed?.freq == entry) {
@@ -1491,6 +1505,18 @@ private fun RecurrencePickerDialog(
.padding(horizontal = 16.dp),
)
// The same rule as dates. Rendered even while the form is
// incomplete (as an empty line) so the controls below don't
// shift as it comes and goes.
Text(
text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
// How often: an interval amount plus a frequency segmented row —
// all four units on show, no unit hidden behind a dropdown.
GroupedSurface(
@@ -2007,6 +2033,11 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
/**
* Visibility selector: one card per level, each with its own icon; the
* current level is highlighted. Tap picks and closes.
*
* Every level carries a line saying who this affects, because the four words
* alone don't: visibility is about what *other people on a shared calendar*
* see, which is exactly the part the label leaves out — and "Confidential" has
* no meaning at all until you know the server decides what it does with it.
*/
@Composable
private fun VisibilityPickerDialog(
@@ -2019,6 +2050,7 @@ private fun VisibilityPickerDialog(
options = AccessLevel.entries.toList(),
selected = selected,
label = { stringResource(accessLevelLabel(it)) },
summary = { stringResource(accessLevelSummary(it)) },
onSelect = onSelect,
onDismiss = onDismiss,
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
@@ -2111,6 +2143,14 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
AccessLevel.Confidential -> R.string.event_access_confidential
}
/** What each level means for people the calendar is shared with. */
private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
AccessLevel.Default -> R.string.event_access_default_summary
AccessLevel.Public -> R.string.event_access_public_summary
AccessLevel.Private -> R.string.event_access_private_summary
AccessLevel.Confidential -> R.string.event_access_confidential_summary
}
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
@Composable
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)

View File

@@ -0,0 +1,543 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
* Appearance: how the app itself looks — theme and colour, the two typeface
* roles, and the launcher name. Anything that only changes how a *calendar view*
* reads (week start, time format, grid options) lives in [ViewsScreen] instead,
* and the widget-only settings in [WidgetsScreen] (#69).
*/
@Composable
internal fun AppearanceScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showTheme by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) }
var showPlainFont by remember { mutableStateOf(false) }
var showAppName by remember { mutableStateOf(false) }
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
// A picked file that didn't parse as a font: tell the user and keep the old choice.
val context = LocalContext.current
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
LaunchedEffect(Unit) {
viewModel.fontImportFailed.collect {
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
}
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
onBack = onBack,
predictiveBack = true,
) {
// Theme & colour
GroupedRow(
title = stringResource(R.string.settings_theme),
summary = themeLabel(state.themeMode),
position = Position.Top,
onClick = { showTheme = true },
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
// Says what it does when on; below Android 12 that is replaced by
// the reason the switch is dead.
summary = if (state.dynamicColorAvailable) {
stringResource(R.string.settings_dynamic_color_summary)
} else {
stringResource(R.string.settings_dynamic_color_unavailable)
},
position = Position.Middle,
trailing = {
Switch(
checked = state.dynamicColor,
onCheckedChange = viewModel::setDynamicColor,
enabled = state.dynamicColorAvailable,
)
},
onClick = if (state.dynamicColorAvailable) {
{ viewModel.setDynamicColor(!state.dynamicColor) }
} else {
null
},
)
GroupedRow(
title = stringResource(R.string.settings_soften_colors),
summary = stringResource(R.string.settings_soften_colors_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.softenColors,
onCheckedChange = viewModel::setSoftenColors,
)
},
onClick = { viewModel.setSoftenColors(!state.softenColors) },
)
Spacer(Modifier.height(16.dp))
// Fonts — the two Material typeface roles, each independently choosable
// (issue #19). Headings = brand (display/headline); body = plain
// (title/body/label). Both default to the system typeface.
GroupedRow(
title = stringResource(R.string.settings_font_headings),
summary = fontLabel(fonts.brand),
position = Position.Top,
onClick = { showBrandFont = true },
)
GroupedRow(
title = stringResource(R.string.settings_font_body),
summary = fontLabel(fonts.plain),
position = Position.Bottom,
onClick = { showPlainFont = true },
)
Spacer(Modifier.height(16.dp))
// App name — chooses the launcher label between "Calendula" and "Calendar"
// (issue #44). Own group: it's a launcher/system concern, not app styling.
// A sub-page chooser (not a switch), matching the app's other "choose one"
// settings and leaving room for more names later.
GroupedRow(
title = stringResource(R.string.settings_app_name),
summary = launcherNameLabel(launcherName),
position = Position.Alone,
onClick = { showAppName = true },
)
}
if (showAppName) {
FullScreenPicker(
title = stringResource(R.string.settings_app_name),
onDismiss = { showAppName = false },
predictiveBack = true,
) {
// Show both names as launcher-mark previews so the user sees what
// they'd switch to, not just the current state. Tapping applies
// immediately and highlights — the picker stays open so the change is
// visible; back exits.
Text(
text = stringResource(R.string.settings_app_name_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
LauncherName.entries.forEach { option ->
AppNameOptionCard(
name = option,
selected = launcherName == option,
onClick = { viewModel.setLauncherName(option) },
modifier = Modifier.weight(1f),
)
}
}
}
}
if (showTheme) {
// No preview here on purpose: picking a theme repaints the app itself,
// which is a better demonstration than any thumbnail. What the list
// *can't* show is which way "Follow the system" currently falls, so that
// one option says so.
val systemDark = isSystemInDarkTheme()
OptionPicker(
title = stringResource(R.string.settings_theme),
header = { PickerDescription(stringResource(R.string.settings_theme_hint)) },
predictiveBack = true,
options = ThemeMode.entries,
selected = state.themeMode,
label = { themeLabel(it) },
summary = { mode ->
if (mode == ThemeMode.SYSTEM) {
stringResource(
R.string.settings_theme_system_summary,
themeLabel(if (systemDark) ThemeMode.DARK else ThemeMode.LIGHT),
)
} else {
null
}
},
onSelect = viewModel::setThemeMode,
onDismiss = { showTheme = false },
)
}
if (showBrandFont) {
FontPicker(
title = stringResource(R.string.settings_font_headings),
role = FontRole.BRAND,
selected = fonts.brand,
stamp = fonts.brandStamp,
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
onDismiss = { showBrandFont = false },
)
}
if (showPlainFont) {
FontPicker(
title = stringResource(R.string.settings_font_body),
role = FontRole.PLAIN,
selected = fonts.plain,
stamp = fonts.plainStamp,
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
onDismiss = { showPlainFont = false },
)
}
}
@Composable
private fun themeLabel(mode: ThemeMode): String = stringResource(
when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
},
)
/** The summary label for a stored font token (issue #19). */
@Composable
private fun fontLabel(token: String): String = when (token) {
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
?: stringResource(R.string.settings_font_system)
}
/** The display name for a launcher-label choice (issue #44). */
@Composable
private fun launcherNameLabel(name: LauncherName): String = stringResource(
when (name) {
LauncherName.CALENDULA -> R.string.app_name
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
},
)
/**
* MIME types offered to the document picker so it lists only font files. Covers
* the modern `font/` types plus the legacy `application/` font aliases some
* providers still report. Anything that slips through is still validated by
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
*/
private val FONT_PICKER_MIME_TYPES = arrayOf(
"font/ttf",
"font/otf",
"font/sfnt",
"font/collection",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-sfnt",
"application/vnd.ms-opentype",
)
/**
* Full-screen font chooser for one [FontRole]: the system default, each bundled
* font (previewed in its own face), and "Choose file…" which opens the system
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
* a file is validated and imported by the caller, switching to the custom font
* on success.
*
* Above the rows sits a specimen of the *current* choice, set in the type role
* this picker governs — headline for [FontRole.BRAND], body for
* [FontRole.PLAIN]. The per-row "Ag" says what a face looks like; only a full
* line at the real size says whether it works for the role, and it is the one
* thing that makes the two font settings distinguishable from each other.
*
* Being a preview picker, it stays open on selection (see `FullScreenPicker`):
* the specimen re-renders instead, which is the whole point of the screen.
*/
@Composable
private fun FontPicker(
title: String,
role: FontRole,
selected: String,
stamp: Int,
onSelect: (String) -> Unit,
onImport: (Uri) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
// Stay open on a successful import too: the specimen switches to the
// imported face, which is the only look the user gets before committing.
if (uri != null) onImport(uri)
}
// System default + the bundled fonts + the "Choose file…" row.
val rowCount = BundledFont.entries.size + 2
val isCustom = selected == FONT_CUSTOM_TOKEN
// Resolving the custom face stats the disk and builds a fresh FontFamily, so
// memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file
// refreshes the preview while plain recompositions reuse the cached family.
val customPreview = remember(role, isCustom, stamp) {
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
}
// The face the specimen is set in: the same resolution the theme performs,
// so what is shown here is what the app will use.
val selectedFamily = remember(role, selected, stamp) {
resolveFontFamily(selected, role, context)
}
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
FontSpecimen(role = role, family = selectedFamily)
FontOptionRow(
label = stringResource(R.string.settings_font_system),
preview = FontFamily.Default,
selected = selected == FONT_SYSTEM_TOKEN,
position = positionOf(0, rowCount),
// Applies straight away and stays open — the specimen above is the
// answer to "what does this one look like".
onClick = { onSelect(FONT_SYSTEM_TOKEN) },
)
BundledFont.entries.forEachIndexed { index, font ->
FontOptionRow(
label = stringResource(font.labelRes),
preview = font.family,
selected = selected == font.token,
position = positionOf(index + 1, rowCount),
onClick = { onSelect(font.token) },
)
}
FontOptionRow(
label = if (isCustom) {
stringResource(R.string.settings_font_custom_selected)
} else {
stringResource(R.string.settings_font_choose_file)
},
// A loaded font previews in its own face; otherwise show an upload cue.
preview = customPreview,
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
selected = isCustom,
position = positionOf(rowCount - 1, rowCount),
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
)
}
}
/**
* A specimen of [family] set in the type role [role] governs: a headline for
* the brand role, a paragraph for the plain one — the same styles the app draws
* with, only the family swapped, so nothing here can flatter a face the app
* won't reproduce. A null [family] is the system typeface, i.e. the Material
* default the styles already carry.
*/
@Composable
private fun FontSpecimen(role: FontRole, family: FontFamily?) {
val isBrand = role == FontRole.BRAND
val style = if (isBrand) {
MaterialTheme.typography.headlineMedium
} else {
MaterialTheme.typography.bodyLarge
}
Text(
text = stringResource(
if (isBrand) R.string.settings_font_specimen_heading else R.string.settings_font_specimen_body,
),
style = style.copy(fontFamily = family ?: style.fontFamily),
color = MaterialTheme.colorScheme.onSurface,
// Two lines up front so switching between a wide and a narrow face
// doesn't shuffle the option list up and down under it.
minLines = 2,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
}
/**
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
* to preview), and a check when it's the current selection.
*/
@Composable
private fun FontOptionRow(
label: String,
preview: FontFamily?,
selected: Boolean,
position: Position,
onClick: () -> Unit,
leadingIcon: ImageVector? = null,
) {
GroupedRow(
title = label,
position = position,
selected = selected,
leading = {
if (preview != null) {
Text(
text = "Ag",
fontFamily = preview,
style = MaterialTheme.typography.titleLarge,
)
} else if (leadingIcon != null) {
Icon(imageVector = leadingIcon, contentDescription = null)
}
},
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
onClick = onClick,
)
}
/**
* One selectable launcher-name preview in the App name picker (issue #44): the
* app's launcher mark over the name, framed as a card. The active one carries a
* primary border, a tinted container and a check; tapping selects it. The mark
* is the same for both — only the label changes — so the card previews exactly
* what the home screen will read.
*/
@Composable
private fun AppNameOptionCard(
name: LauncherName,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(24.dp)
val borderColor = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
}
val containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
Column(
modifier = modifier
.clip(shape)
.background(containerColor)
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
.clickable(onClick = onClick)
.padding(vertical = 20.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// The adaptive launcher mark, reconstructed as a squircle (as in the
// onboarding BrandHero) so it renders identically everywhere.
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(18.dp))
.background(colorResource(R.color.ic_launcher_background)),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
Text(
text = launcherNameLabel(name),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 1,
)
// Selection indicator: a filled check when active, an empty ring otherwise.
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
.then(
if (selected) {
Modifier
} else {
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
},
),
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp),
)
}
}
}
}

View File

@@ -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,
)
},
)
}
}

View File

@@ -0,0 +1,410 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import kotlinx.datetime.LocalTime
/**
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
* the pref is set either way; the OS permission is the real gate.
*/
@Composable
internal fun NotificationsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
onOpenSpecialDates: () -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
val toggleReminders: (Boolean) -> Unit = { enabled ->
viewModel.setRemindersEnabled(enabled)
val needsPermission = enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context, Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
if (needsPermission) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
var showDefaultReminder by remember { mutableStateOf(false) }
var showAllDayReminder by remember { mutableStateOf(false) }
var showAllDayReminderTime by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var calendarSectionExpanded by remember { mutableStateOf(false) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_notifications),
onBack = onBack,
predictiveBack = true,
) {
GroupedRow(
title = stringResource(R.string.settings_reminders),
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Top,
trailing = {
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
},
onClick = { toggleReminders(!state.remindersEnabled) },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderChoiceLabel(state.defaultReminderMinutes),
position = Position.Middle,
onClick = { showDefaultReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
position = Position.Middle,
onClick = { showAllDayReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_allday_reminder_time),
summary = stringResource(
R.string.settings_allday_reminder_time_hint,
settingsTimeOfDay(state.allDayReminderTimeMinutes),
),
position = Position.Bottom,
onClick = { showAllDayReminderTime = true },
)
// Delivery reliability + snooze: both are global reminder-delivery
// settings, so they sit with the defaults above rather than below the
// long per-calendar list. Reliability is a soft, optional battery-
// optimisation exemption (system-settings deep-link, no special
// permission); shown as live status, reversible at any time.
Spacer(Modifier.height(24.dp))
val batteryExempt = rememberBatteryOptimizationExempt()
GroupedRow(
title = stringResource(R.string.settings_reliable_delivery),
summary = if (batteryExempt) {
stringResource(R.string.settings_reliable_delivery_exempt)
} else {
stringResource(R.string.settings_reliable_delivery_hint)
},
position = Position.Top,
trailing = if (batteryExempt) {
{ SelectedCheck() }
} else {
null
},
onClick = { openBatteryOptimizationSettings(context) },
)
// Snooze: how long the notification's "Snooze" action defers a reminder.
GroupedRow(
title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes),
position = Position.Bottom,
onClick = { showSnooze = true },
)
// Per-calendar overrides: the whole section folds behind one header to
// keep the screen tidy. Expanded, each writable calendar gets its own
// expandable card that may keep, drop, or replace the global default —
// separately for timed and all-day events.
if (state.writableCalendars.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_calendar_reminders_title),
summary = stringResource(R.string.settings_calendar_reminders_hint),
position = Position.Alone,
trailing = {
Icon(
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
)
AnimatedVisibility(
visible = calendarSectionExpanded,
enter = expandEnter(),
exit = collapseExit(),
) {
Column {
state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp))
// A contact special-dates calendar owns its reminders in
// its own section — link there instead of an override.
if (calendar.id in state.managedCalendarIds) {
GroupedRow(
title = calendar.displayName,
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenSpecialDates,
)
return@forEach
}
val expanded = calendar.id in expandedCalendars
// Calendar card; tapping expands it into a grouped list
// of three (the card + the timed and all-day rows).
GroupedRow(
title = calendar.displayName,
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = {
expandedCalendars = if (expanded) {
expandedCalendars - calendar.id
} else {
expandedCalendars + calendar.id
}
},
)
AnimatedVisibility(
visible = expanded,
enter = expandEnter(),
exit = collapseExit(),
) {
Column {
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
position = Position.Middle,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
)
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
position = Position.Bottom,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
)
}
}
}
}
}
}
}
if (showSnooze) {
SnoozeDurationPicker(
title = stringResource(R.string.settings_snooze_duration),
presets = SNOOZE_PRESETS,
selected = state.snoozeMinutes,
label = { snoozeDurationLabel(it) },
onSelect = { viewModel.setSnoozeMinutes(it) },
onDismiss = { showSnooze = false },
)
}
if (showDefaultReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder),
presets = REMINDER_PRESETS,
selected = state.defaultReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
onDismiss = { showDefaultReminder = false },
)
}
if (showAllDayReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder_allday),
presets = ALLDAY_REMINDER_PRESETS,
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
allowInherit = false,
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
onDismiss = { showAllDayReminder = false },
leadTimeSummary = allDayFiringTimeSummary(state.allDayReminderTimeMinutes),
)
}
if (showAllDayReminderTime) {
TimePickerAlert(
initial = LocalTime(
state.allDayReminderTimeMinutes / 60,
state.allDayReminderTimeMinutes % 60,
),
onConfirm = {
viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute)
showAllDayReminderTime = false
},
onDismiss = { showAllDayReminderTime = false },
)
}
overrideDialog?.let { target ->
val map = if (target.isAllDay) {
state.perCalendarAllDayReminderOverride
} else {
state.perCalendarReminderOverride
}
ReminderDefaultPicker(
title = stringResource(
if (target.isAllDay) {
R.string.settings_default_reminder_allday
} else {
R.string.settings_default_reminder
},
),
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
selected = map.reminderOverrideFor(target.calendarId),
allowInherit = true,
onSelect = {
if (target.isAllDay) {
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
} else {
viewModel.setCalendarReminderOverride(target.calendarId, it)
}
},
onDismiss = { overrideDialog = null },
leadTimeSummary = if (target.isAllDay) {
allDayFiringTimeSummary(state.allDayReminderTimeMinutes)
} else {
null
},
)
}
}
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
/** A global default (empty = none) as a picker choice for selection highlighting. */
private fun List<Int>.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<Int> =
(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<Int>,
): String = when (choice) {
ReminderOverride.Inherit ->
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
ReminderOverride.None -> stringResource(R.string.reminder_none)
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
}
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
@Composable
private fun snoozeDurationLabel(minutes: Int): String =
if (minutes % 60 == 0) {
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
} else {
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
}
/**
* Whether Calendula is exempt from battery optimisation, re-read on every
* `ON_RESUME` so the row reflects a change the user just made in system
* settings without needing to leave and re-enter the screen.
*/
@Composable
private fun rememberBatteryOptimizationExempt(): Boolean {
val context = LocalContext.current
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
exempt = isIgnoringBatteryOptimizations(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
return exempt
}
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return power.isIgnoringBatteryOptimizations(context.packageName)
}
/**
* Take the user straight to Calendula's exemption: the direct
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore
* battery optimisation?") rather than the full app list they'd have to scroll.
* Falls back to the optimisation list if the OS refuses the direct intent.
*/
private fun openBatteryOptimizationSettings(context: Context) {
val direct = Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
"package:${context.packageName}".toUri(),
)
if (runCatching { context.startActivity(direct) }.isFailure) {
runCatching {
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
}
}

View File

@@ -0,0 +1,206 @@
package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* The "past events" chooser for the Agenda (#69 follow-up).
*
* "Show / dim / hide" is the case where the word cannot carry the choice —
* *how far* does dimmed fade, and what is left behind when finished events are
* hidden? So a stand-in agenda day sits above the options and re-renders as you
* pick one, using the very rows the Agenda draws: dimming is
* [AgendaEventRow]'s own `dimmed` flag, and hiding drops the row exactly as
* [de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen] filters it — so the
* preview cannot claim an effect the screen doesn't have.
*
* Like the Month style and App name pickers, selecting applies immediately and
* leaves the picker open: closing would hide the thing the screen is for. Back
* exits.
*/
@Composable
internal fun PastEventsPicker(
selected: PastEventDisplay,
onSelect: (PastEventDisplay) -> Unit,
onDismiss: () -> Unit,
) {
val options = PastEventDisplay.entries
val reduceMotion = rememberReduceMotion()
FullScreenPicker(
title = stringResource(R.string.settings_past_events),
onDismiss = onDismiss,
predictiveBack = true,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.height(PREVIEW_HEIGHT),
contentAlignment = Alignment.TopCenter,
) {
Crossfade(
targetState = selected,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = "past-events-preview",
) { shown ->
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
PastEventsPreview(mode = shown)
}
}
}
PickerDescription(stringResource(R.string.settings_past_events_hint))
options.forEachIndexed { index, mode ->
val isSelected = mode == selected
GroupedRow(
title = stringResource(pastEventDisplayLabelRes(mode)),
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
// Applies straight away; the preview above is the confirmation.
onClick = { onSelect(mode) },
)
}
}
}
/**
* A stand-in agenda day in a given [mode]: three events on today, the first two
* already finished. The times are fixed rather than derived from the clock, so
* the preview reads the same at any hour — what varies is only what [mode] does
* to the two finished rows.
*/
@Composable
private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modifier) {
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val titles = listOf(
stringResource(R.string.settings_past_events_sample_morning),
stringResource(R.string.settings_past_events_sample_midday),
stringResource(R.string.settings_past_events_sample_evening),
)
val sample = remember(today, zone, titles) { samplePastDay(today, zone, titles) }
// Hiding drops the finished rows outright; showing and dimming keep them,
// and only dimming fades them — the same three-way split AgendaContent makes.
val visible = if (mode == PastEventDisplay.HIDE) sample.filterNot { it.hasPassed } else sample
Column(
modifier = modifier
.fillMaxWidth()
.clipToBounds()
// A preview is a picture, not a control: swallow touches before the
// rows' own clickables see them, and keep it out of the reading order —
// the options below already say what is selected.
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
}
}
}
.clearAndSetSemantics { },
) {
AgendaDayHeader(date = today, today = today, onOpenDay = {})
visible.forEachIndexed { index, row ->
AgendaEventRow(
event = row.event,
day = today,
zone = zone,
position = positionOf(index, visible.size),
dimmed = mode == PastEventDisplay.DIM && row.hasPassed,
onClick = {},
)
}
}
}
/** One stand-in row plus whether it counts as finished. */
private class SampleRow(val event: EventInstance, val hasPassed: Boolean)
/**
* Today's stand-in events, [titles] in order: two finished (morning, midday) and
* one still to come.
*
* Colours are raw ARGB on purpose — that is what the provider hands out for an
* event, so a theme token here would misrepresent what the agenda renders.
*/
private fun samplePastDay(today: LocalDate, zone: TimeZone, titles: List<String>): List<SampleRow> =
listOf(
sampleRow(1, titles[0], today, zone, 9, 10, 0xFF3F7BD4.toInt(), hasPassed = true),
sampleRow(2, titles[1], today, zone, 12, 13, 0xFFCE5B4C.toInt(), hasPassed = true),
sampleRow(3, titles[2], today, zone, 18, 19, 0xFF4E9A6A.toInt(), hasPassed = false),
)
private fun sampleRow(
id: Long,
title: String,
day: LocalDate,
zone: TimeZone,
startHour: Int,
endHour: Int,
color: Int,
hasPassed: Boolean,
): SampleRow = SampleRow(
event = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = day.atTime(startHour, 0).toInstant(zone),
end = day.atTime(endHour, 0).toInstant(zone),
isAllDay = false,
color = color,
location = null,
),
hasPassed = hasPassed,
)
/** Room for the day header plus its three two-line rows, so hiding shortens the
* list inside a box that keeps its height instead of the page jumping a row. */
private val PREVIEW_HEIGHT = 288.dp
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -0,0 +1,156 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.locale.currentLocale
/**
* Pieces shared by the settings hub and its sub-screens (`*Settings.kt`). Each
* sub-screen owns whatever only it uses; anything two of them need lives here.
*/
/**
* Token-based accent for a leading icon chip (container / on-container pair).
* Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt
* to theme, dark mode and dynamic colour.
*
* The chips are a scanning aid, so the rule is **no two rows in one group share
* an accent** — a group in a single colour carries no more information than no
* colour at all. [Neutral] is not a fourth colour to rotate through but a
* deliberate step back, for rows that are reference or last-resort rather than
* somewhere you routinely go.
*/
internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary }
/**
* Leading circular icon chip. Colours come from the M3 scheme via a container /
* on-container token pair, so each accent stays correctly paired across theme,
* dark mode and dynamic colour.
*/
@Composable
internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
val scheme = MaterialTheme.colorScheme
val (background, iconColor) = when (accent) {
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
ChipAccent.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
}
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(background),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(22.dp),
)
}
}
/**
* A small primary-coloured group label, matching the Calendars settings screen.
*
* Starts on [GroupedListInset] — the cards' own edge — so a header, the hint
* under it and the group it names all share one left margin.
*/
@Composable
internal fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
@Composable
internal fun SettingsHint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
)
}
internal fun openUrl(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
runCatching { context.startActivity(intent) }
}
/** Label for a global-default choice: empty → "None", else the lead times joined. */
@Composable
internal fun reminderChoiceLabel(minutes: List<Int>): String {
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
}
/**
* Lead times offered for the all-day default — day-scale, since a "minutes
* before midnight" reminder on an all-day event is rarely what's wanted. Shared
* with the contact special-dates calendars, whose events are all all-day.
*/
internal val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
/**
* A minute-of-day ("09:00") in the app's *own* 12/24-hour convention — the same
* [LocalUse24HourFormat] the agenda and event rows read, so a time shown in
* settings can't disagree with the times shown everywhere else.
*/
@Composable
internal fun settingsTimeOfDay(minutesOfDay: Int): String =
formatMinuteOfDay(minutesOfDay, LocalUse24HourFormat.current, currentLocale())
/**
* Second line for an all-day lead-time row: the clock time it fires at.
*
* An all-day occurrence has no time of its own, so a reminder's stored offset
* only decides *which day* it belongs to — the hour always comes from the
* global "show all-day reminders at" setting (see
* [de.jeanlucmakiola.calendula.domain.reminders.planReminders]). Naming it on
* every row is what makes "At time of event" readable on an all-day default at
* all: it means that day at 09:00, not midnight.
*
* Shared by the notification defaults, the per-calendar overrides and the
* contact special-dates calendars — all three read the same one setting.
*/
@Composable
internal fun allDayFiringTimeSummary(allDayReminderTimeMinutes: Int): @Composable (Int) -> String? {
val summary = stringResource(
R.string.settings_allday_reminder_fires_at,
settingsTimeOfDay(allDayReminderTimeMinutes),
)
return { summary }
}

View File

@@ -0,0 +1,327 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.reminders.ReminderOverride
// ---------------------------------------------------------------------------
// Contact special dates (issue #15)
// ---------------------------------------------------------------------------
@Composable
internal fun SpecialDatesScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
// The all-day reminder hour lives in the general settings state; the reminder
// picker below names it, since that is when these reminders actually fire.
val settings by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
// READ_CONTACTS is requested only here, on enable — never at startup. On a
// grant we either enable (if turning on) or just re-sync (clearing a stalled
// banner after the permission was re-granted).
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow()
}
}
val requestOrEnable: () -> Unit = {
if (context.hasContactsPermission()) {
viewModel.setSpecialDatesEnabled(true)
} else {
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
}
var confirmDisableAll by remember { mutableStateOf(false) }
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
var reminderPickerType by remember { mutableStateOf<SpecialDateType?>(null) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_special_dates),
onBack = onBack,
predictiveBack = true,
) {
// Paused banner: the permission was revoked after enabling.
if (state.enabled && state.stalledPermission) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.settings_special_dates_paused_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringResource(R.string.settings_special_dates_paused_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
) { Text(stringResource(R.string.settings_special_dates_grant)) }
}
}
Spacer(Modifier.height(16.dp))
}
GroupedRow(
title = stringResource(R.string.settings_special_dates_enable),
summary = stringResource(R.string.settings_special_dates_enable_hint),
position = Position.Alone,
trailing = {
Switch(
checked = state.enabled,
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
)
},
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
)
if (state.enabled) {
// Per-type card: the toggle, and while on, its title format and its
// calendar-wide reminder.
SpecialDateType.entries.forEachIndexed { index, type ->
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
val on = type in state.types
GroupedRow(
title = stringResource(specialDateTypeLabel(type)),
position = if (on) Position.Top else Position.Alone,
trailing = {
Switch(
checked = on,
onCheckedChange = { want ->
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
else confirmDisableType = type
},
)
},
onClick = {
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
},
)
if (on) {
GroupedRow(
title = stringResource(R.string.settings_special_dates_template),
summary = state.titleTemplates[type].orEmpty(),
position = Position.Middle,
onClick = { editTemplate = type },
)
GroupedRow(
title = stringResource(R.string.settings_special_dates_reminders),
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
position = Position.Bottom,
onClick = { reminderPickerType = type },
)
}
}
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_special_dates_show_year),
summary = stringResource(R.string.settings_special_dates_show_year_hint),
position = Position.Top,
trailing = {
Switch(
checked = state.showYear,
onCheckedChange = viewModel::setSpecialDatesShowYear,
)
},
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
)
GroupedRow(
title = stringResource(R.string.settings_special_dates_sync_now),
summary = specialDatesLastRunLabel(context, state.lastRun),
position = Position.Bottom,
onClick = viewModel::syncSpecialDatesNow,
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.settings_special_dates_calendar_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
if (confirmDisableAll) {
SpecialDatesDisableDialog(
message = stringResource(R.string.settings_special_dates_disable_all_message),
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
onDismiss = { confirmDisableAll = false },
)
}
confirmDisableType?.let { type ->
SpecialDatesDisableDialog(
message = stringResource(
R.string.settings_special_dates_disable_type_message,
stringResource(specialDateTypeLabel(type)),
),
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
onDismiss = { confirmDisableType = null },
)
}
editTemplate?.let { type ->
SpecialDatesTemplateDialog(
initial = state.titleTemplates[type].orEmpty(),
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
onDismiss = { editTemplate = null },
)
}
reminderPickerType?.let { type ->
ReminderDefaultPicker(
title = stringResource(R.string.settings_special_dates_reminders),
presets = ALLDAY_REMINDER_PRESETS,
selected = state.reminderChoices[type] ?: ReminderOverride.None,
// Managed calendars own their reminders outright — no "inherit global".
allowInherit = false,
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
onDismiss = { reminderPickerType = null },
// Special dates are all-day events, so their reminders fire at the
// one global all-day hour like any other all-day reminder.
leadTimeSummary = allDayFiringTimeSummary(settings.allDayReminderTimeMinutes),
)
}
}
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
private fun specialDatesReminderMinutes(choice: ReminderOverride?): List<Int> =
(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(),
)
}

View File

@@ -0,0 +1,428 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.text.format.DateFormat
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.ReorderableColumn
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.datetime.DayOfWeek
import java.time.format.TextStyle as JavaTextStyle
/**
* Views: everything that changes how a calendar view reads, grouped by the view
* it belongs to, plus the two cross-view ordering lists (#24, #69).
*
* The first group holds what applies everywhere (default view, week start, time
* format); the rest are per-view. Anything that styles the *app* rather than a
* view (theme, fonts) is in [AppearanceScreen]; the widget's own copies of the
* agenda settings are in [WidgetsScreen].
*
* The quick-switch cycle and the navigation-drawer list are two independent
* orders — a view disabled in the quick-switch cycle is still reachable from the
* drawer, which always lists every view. The switch needs at least two targets,
* so the last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
*/
@Composable
internal fun ViewsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showMonthStyle by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
var showTimeFormat by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_views),
onBack = onBack,
predictiveBack = true,
) {
val config = state.quickSwitchConfig
// What holds for every view, above the per-view groups.
SectionHeader(stringResource(R.string.settings_views_all_header))
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
position = Position.Top,
onClick = { showDefaultView = true },
)
GroupedRow(
title = stringResource(R.string.settings_week_start),
summary = weekStartLabel(state.weekStart),
position = Position.Middle,
onClick = { showWeekStart = true },
)
GroupedRow(
title = stringResource(R.string.settings_time_format),
summary = timeFormatLabel(state.timeFormat),
position = Position.Middle,
onClick = { showTimeFormat = true },
)
GroupedRow(
title = stringResource(R.string.settings_today_toolbar),
summary = stringResource(R.string.settings_today_toolbar_summary),
position = Position.Middle,
trailing = {
Switch(
checked = state.todayButtonInToolbar,
onCheckedChange = viewModel::setTodayButtonInToolbar,
)
},
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
)
GroupedRow(
title = stringResource(R.string.settings_dim_completed),
summary = stringResource(R.string.settings_dim_completed_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.dimCompletedEvents,
onCheckedChange = viewModel::setDimCompletedEvents,
)
},
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
)
Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_month_header))
GroupedRow(
title = stringResource(R.string.settings_month_view_style),
summary = stringResource(state.monthViewStyle.labelRes),
position = Position.Top,
onClick = { showMonthStyle = true },
)
GroupedRow(
title = stringResource(R.string.settings_week_numbers),
summary = stringResource(R.string.settings_week_numbers_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.showWeekNumbers,
onCheckedChange = viewModel::setShowWeekNumbers,
)
},
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
)
Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_week_day_header))
GroupedRow(
title = stringResource(R.string.settings_hour_lines),
summary = stringResource(R.string.settings_hour_lines_summary),
position = Position.Alone,
trailing = {
Switch(
checked = state.showHourLines,
onCheckedChange = viewModel::setShowHourLines,
)
},
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
)
Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_agenda_header))
GroupedRow(
title = stringResource(R.string.settings_agenda_range),
summary = agendaRangeLabel(state.agendaScreenRange),
position = Position.Top,
onClick = { showAgendaScreenRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_past_events),
summary = pastEventDisplayLabel(state.pastEventDisplay),
position = Position.Middle,
onClick = { showPastEvents = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_show_today),
summary = stringResource(R.string.settings_agenda_show_today_hint),
position = Position.Middle,
trailing = {
Switch(
checked = state.agendaShowToday,
onCheckedChange = viewModel::setAgendaShowToday,
)
},
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.agendaShowRangeBar,
onCheckedChange = viewModel::setAgendaShowRangeBar,
)
},
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
)
Spacer(Modifier.height(24.dp))
SectionHeader(stringResource(R.string.settings_quick_switch_header))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn(
items = config.order,
keyOf = { it },
onReorder = { viewModel.setQuickSwitchOrder(it) },
) { view, position, dragHandle, isDragging ->
val checked = view in config.enabled
ViewRow(
view = view,
position = position,
isDragging = isDragging,
dragHandle = dragHandle,
dimmed = !checked,
trailing = {
Switch(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
)
},
)
}
Spacer(Modifier.height(24.dp))
SectionHeader(stringResource(R.string.settings_drawer_order_header))
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
Spacer(Modifier.height(8.dp))
ReorderableColumn(
items = state.drawerViewOrder,
keyOf = { it },
onReorder = { viewModel.setDrawerViewOrder(it) },
) { view, position, dragHandle, isDragging ->
ViewRow(
view = view,
position = position,
isDragging = isDragging,
dragHandle = dragHandle,
)
}
}
if (showMonthStyle) {
MonthViewStylePicker(
selected = state.monthViewStyle,
// The preview is a real grid, so it uses the real week start too.
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
onSelect = viewModel::setMonthViewStyle,
onDismiss = { showMonthStyle = false },
)
}
if (showDefaultView) {
OptionPicker(
title = stringResource(R.string.settings_default_view),
header = { PickerDescription(stringResource(R.string.settings_default_view_hint)) },
predictiveBack = true,
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
// The same icon each view carries in the drawer and the switcher
// pill, so the row is matched to the view by eye, not by reading.
leading = {
Icon(
imageVector = it.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onSelect = viewModel::setDefaultView,
onDismiss = { showDefaultView = false },
)
}
if (showWeekStart) {
WeekStartPicker(
selected = state.weekStart,
// The preview is a real grid, so it uses the user's own month style.
monthStyle = state.monthViewStyle,
options = WEEK_START_OPTIONS,
onSelect = viewModel::setWeekStart,
onDismiss = { showWeekStart = false },
)
}
if (showTimeFormat) {
val locale = currentLocale()
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
OptionPicker(
title = stringResource(R.string.settings_time_format),
header = { PickerDescription(stringResource(R.string.settings_time_format_hint)) },
predictiveBack = true,
options = TimeFormatPref.entries,
selected = state.timeFormat,
label = { timeFormatLabel(it) },
// Each option renders the same sample time the way it would write
// it — an afternoon one, since that is where 12h and 24h diverge.
// "Automatic" additionally says which of the two it resolves to now.
summary = { pref ->
val sample = formatTimeOfDay(
hour = SAMPLE_HOUR,
minute = SAMPLE_MINUTE,
is24Hour = when (pref) {
TimeFormatPref.AUTO -> systemIs24Hour
TimeFormatPref.TWELVE_HOUR -> false
TimeFormatPref.TWENTY_FOUR_HOUR -> true
},
locale = locale,
)
if (pref == TimeFormatPref.AUTO) {
stringResource(R.string.settings_time_format_auto_summary, sample)
} else {
sample
}
},
onSelect = viewModel::setTimeFormat,
onDismiss = { showTimeFormat = false },
)
}
if (showPastEvents) {
PastEventsPicker(
selected = state.pastEventDisplay,
onSelect = viewModel::setPastEventDisplay,
onDismiss = { showPastEvents = false },
)
}
if (showAgendaScreenRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.settings_agenda_range_hint),
selected = state.agendaScreenRange,
// Resolving each option to real dates needs the same week start the
// agenda itself windows by.
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
onSelect = viewModel::setAgendaScreenRange,
onDismiss = { showAgendaScreenRange = false },
)
}
}
/** Sample time the format options render: afternoon, where 12h and 24h differ. */
private const val SAMPLE_HOUR = 14
private const val SAMPLE_MINUTE = 30
/** One reorderable view row: the view's icon and name, an optional [trailing]
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
@Composable
private fun ViewRow(
view: CalendarView,
position: Position,
isDragging: Boolean,
dragHandle: Modifier,
dimmed: Boolean = false,
trailing: @Composable (() -> Unit)? = null,
) {
GroupedRow(
title = stringResource(view.labelRes),
position = position,
dimmed = dimmed,
minHeight = ReorderableRowHeight,
// The reorderable column owns the inter-row spacing (uniform pitch).
gapBelow = false,
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
leading = {
Icon(
imageVector = view.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Row(verticalAlignment = Alignment.CenterVertically) {
trailing?.invoke()
if (trailing != null) Spacer(Modifier.width(8.dp))
Box(
modifier = dragHandle.size(48.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.reorder_drag_handle),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
)
}
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
private val WEEK_START_OPTIONS: List<WeekStartPref> =
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
@Composable
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
// Localised full weekday name, so any of the seven days reads naturally
// without a per-day string resource.
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
.getDisplayName(JavaTextStyle.FULL, currentLocale())
}
@Composable
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
when (pref) {
TimeFormatPref.AUTO -> R.string.settings_time_format_auto
TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
},
)
@Composable
private fun pastEventDisplayLabel(mode: PastEventDisplay): String =
stringResource(pastEventDisplayLabelRes(mode))
/** Shared with [PastEventsPicker], which needs the id rather than the string. */
@StringRes
internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode) {
PastEventDisplay.SHOW -> R.string.settings_past_events_show
PastEventDisplay.DIM -> R.string.settings_past_events_dim
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
}

View File

@@ -0,0 +1,121 @@
package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
/**
* The week-start chooser, with the grid it rearranges shown above it.
*
* Which column a week opens on is the most visual setting in the app, and the
* one hardest to hold in your head from a weekday name alone. The preview is
* the same [MonthStylePreview] the Month style picker uses, drawn in the user's
* *own* month style, so what moves is exactly what will move on the real
* screen. It is short here (the option list runs to eight rows), which the
* preview supports natively by scaling.
*
* "Follow the system" carries the day it currently resolves to as its summary —
* otherwise the one automatic option is the only one whose effect is invisible.
*
* A preview picker, so selecting applies at once and keeps the picker open;
* back exits (see `FullScreenPicker`).
*/
@Composable
internal fun WeekStartPicker(
selected: WeekStartPref,
monthStyle: MonthViewStyle,
options: List<WeekStartPref>,
onSelect: (WeekStartPref) -> Unit,
onDismiss: () -> Unit,
) {
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
val resolved = selected.resolveFirstDay(locale)
FullScreenPicker(
title = stringResource(R.string.settings_week_start),
onDismiss = onDismiss,
predictiveBack = true,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.height(PREVIEW_HEIGHT),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState = resolved,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = "week-start-preview",
) { day ->
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
MonthStylePreview(
style = monthStyle,
weekStart = day,
height = PREVIEW_HEIGHT,
)
}
}
}
PickerDescription(stringResource(R.string.settings_week_start_hint))
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = weekStartLabel(option),
// Only the automatic option needs a second line: it is the one
// whose effect the label doesn't name.
summary = if (option == WeekStartPref.Auto) {
stringResource(
R.string.settings_week_start_auto_summary,
weekStartLabel(WeekStartPref.Day(option.resolveFirstDay(locale))),
)
} else {
null
},
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(option) },
)
}
}
}
/** Short enough to leave the first options on screen under it. */
private val PREVIEW_HEIGHT = 200.dp
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -0,0 +1,160 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.app.StatusBarManager
import android.content.ComponentName
import android.content.Context
import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlin.math.roundToInt
/**
* Widgets & tiles (#69): the home-screen widgets' own settings and the Quick
* Settings tile shortcut — the app's system surfaces, collected in one place.
*
* The agenda widget keeps its own range and text size, deliberately separate
* from the Agenda *screen*'s range in [ViewsScreen]: a widget is glanced at, a
* screen is browsed, and having the two rows sit next to each other (as they did
* before) made them easy to mistake for one another.
*/
@Composable
internal fun WidgetsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_widgets),
onBack = onBack,
predictiveBack = true,
) {
SettingsHint(stringResource(R.string.settings_widgets_hint))
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.settings_agenda_widget_range),
summary = agendaRangeLabel(state.agendaWidgetRange),
position = Position.Top,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_widget_size),
summary = widgetSizeLabel(state.widgetSize),
position = Position.Bottom,
onClick = { showWidgetSize = true },
)
// One-tap add of the "New event" Quick Settings tile. The system prompt
// is API 33+; on older versions the tile is still addable manually from
// the QS editor, so the row simply doesn't appear here.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Spacer(Modifier.height(24.dp))
QuickSettingsTileRow()
}
}
if (showAgendaWidgetRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_widget_range),
description = stringResource(R.string.settings_agenda_widget_range_hint),
selected = state.agendaWidgetRange,
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
onSelect = viewModel::setAgendaWidgetRange,
onDismiss = { showAgendaWidgetRange = false },
)
}
if (showWidgetSize) {
OptionPicker(
title = stringResource(R.string.settings_widget_size),
header = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) },
predictiveBack = true,
options = WidgetSize.entries,
selected = state.widgetSize,
label = { widgetSizeLabel(it) },
// A size name says nothing about what you get; the text scale it
// draws at does. No live preview here on purpose: the widget is
// Glance/RemoteViews, so a Compose mock-up would be a second
// implementation free to drift from the real thing.
summary = { widgetSizeSummary(it) },
onSelect = viewModel::setWidgetSize,
onDismiss = { showWidgetSize = false },
)
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
private fun QuickSettingsTileRow() {
val context = LocalContext.current
GroupedRow(
title = stringResource(R.string.settings_qs_tile),
summary = stringResource(R.string.settings_qs_tile_hint),
position = Position.Alone,
onClick = { requestAddQsTile(context) },
)
}
/**
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
* shows its own confirmation dialog and handles the already-added case, so no
* result handling is needed here.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun requestAddQsTile(context: Context) {
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
statusBar.requestAddTileService(
ComponentName(context, NewEventTileService::class.java),
context.getString(R.string.qs_tile_new_event_label),
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ }
}
/**
* What a size step actually does, as a percentage of the smallest one's event
* text. Derived from the widget's own [metricsFor] table rather than written
* out here, so a retuned step can't leave this line claiming the old number.
*/
@Composable
private fun widgetSizeSummary(size: WidgetSize): String {
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
return stringResource(R.string.settings_widget_size_summary, percent)
}
@Composable
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
when (size) {
WidgetSize.SMALL -> R.string.settings_widget_size_small
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
WidgetSize.LARGE -> R.string.settings_widget_size_large
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
},
)

View File

@@ -155,6 +155,9 @@
<!-- Shown in place of the live rule read-out when an amount field holds a
value outside 1999 (i.e. 0), so the greyed-out OK button has a reason. -->
<string name="event_edit_recurrence_incomplete">Enter a number from 1 to 999</string>
<!-- %1$s is a list of the next few dates, e.g. "30 Jul, 6 Aug and 13 Aug". -->
<string name="event_edit_recurrence_next">Next: %1$s</string>
<string name="event_edit_recurrence_next_none">This rule never repeats</string>
<string name="event_edit_recurrence_ends">Ends</string>
<string name="event_edit_recurrence_end_never">Never</string>
<string name="event_edit_recurrence_end_until">On a date</string>
@@ -198,6 +201,12 @@
<string name="event_availability_free">Free</string>
<string name="event_access_private">Private</string>
<string name="event_access_confidential">Confidential</string>
<!-- Second lines in the visibility picker: what each level means to people
the calendar is shared with. -->
<string name="event_access_default_summary">Whatever this calendar normally does</string>
<string name="event_access_public_summary">Everyone with access sees the full details</string>
<string name="event_access_private_summary">Others see only that you\'re busy</string>
<string name="event_access_confidential_summary">Marked confidential; what that means is up to the calendar account</string>
<string name="event_attendee_organizer">Organizer</string>
<string name="event_attendee_optional">Optional</string>
<string name="event_attendee_resource">Resource</string>
@@ -315,6 +324,9 @@
<string name="settings_theme_system">System</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_theme_hint">Whether the app is light or dark. The choice applies immediately.</string>
<!-- %1$s is the theme the system currently resolves to, e.g. "Dark". -->
<string name="settings_theme_system_summary">Currently %1$s</string>
<string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
@@ -329,8 +341,14 @@
<string name="settings_font_choose_file">Choose file…</string>
<string name="settings_font_custom_selected">Custom font</string>
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
<!-- Specimen line shown in the headings-font picker, set in the chosen face. -->
<string name="settings_font_specimen_heading">Thursday, 14 May</string>
<!-- Specimen paragraph shown in the body-font picker, set in the chosen face. -->
<string name="settings_font_specimen_body">Team review at 10:00, then lunch with Robin at the café on Marktplatz.</string>
<string name="settings_week_start">Week starts on</string>
<string name="settings_week_start_auto">Automatic</string>
<!-- %1$s is the weekday the automatic setting currently resolves to, e.g. "Monday". -->
<string name="settings_week_start_auto_summary">Currently %1$s</string>
<string name="settings_week_numbers">Week numbers</string>
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
<string name="settings_today_toolbar">Today button in toolbar</string>
@@ -341,6 +359,8 @@
<string name="settings_time_format_auto">Automatic</string>
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
<string name="settings_time_format_24h">24-hour (14:00)</string>
<!-- %1$s is a sample time written the way the system currently writes it. -->
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
<string name="settings_hour_lines">Hour lines</string>
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
<string name="settings_dim_completed">Dim completed events</string>
@@ -349,6 +369,10 @@
<string name="settings_past_events_show">Show</string>
<string name="settings_past_events_dim">Dim</string>
<string name="settings_past_events_hide">Hide</string>
<!-- Stand-in event titles in the past-events preview. Short, everyday entries. -->
<string name="settings_past_events_sample_morning">Team review</string>
<string name="settings_past_events_sample_midday">Lunch with Robin</string>
<string name="settings_past_events_sample_evening">Choir practice</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Agenda range</string>
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
@@ -361,6 +385,8 @@
<string name="settings_widget_size_medium">Medium</string>
<string name="settings_widget_size_large">Large</string>
<string name="settings_widget_size_extra_large">Extra large</string>
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
<string name="settings_widget_size_summary">Event text %1$d%%</string>
<string name="settings_agenda_show_today">Always show today</string>
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
<string name="settings_agenda_range_bar">Range bar</string>
@@ -411,6 +437,8 @@
<string name="settings_default_reminder_allday">All-day events</string>
<string name="settings_allday_reminder_time">All-day reminder time</string>
<string name="settings_allday_reminder_time_hint">Reminders for all-day events fire at %1$s</string>
<!-- Second line on an all-day lead-time row. %1$s is a clock time, e.g. "09:00". -->
<string name="settings_allday_reminder_fires_at">Notifies at %1$s</string>
<string name="reminder_none">None</string>
<string name="reminder_use_default">Use default reminder</string>
<string name="reminder_custom_amount">Amount</string>
@@ -430,12 +458,39 @@
<string name="settings_language_auto">System default</string>
<string name="settings_translate">Help translate</string>
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
<!-- Hub group headers (#69) -->
<string name="settings_group_look">Look &amp; behaviour</string>
<string name="settings_group_data">Data</string>
<string name="settings_group_app">App</string>
<!-- Group of reference links at the foot of the settings hub. -->
<string name="settings_group_about">About</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_views_subtitle">Month layout, quick-switch button, menu order</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_appearance_subtitle">Theme, colours, fonts</string>
<string name="settings_views_subtitle">Default view, layout, order</string>
<string name="settings_event_form_subtitle">Default fields and behaviour</string>
<string name="settings_notifications_subtitle">Reminders and delivery</string>
<string name="settings_special_dates_subtitle">Contact birthdays &amp; anniversaries</string>
<string name="settings_widgets_subtitle">Agenda widget, Quick Settings tile</string>
<string name="settings_backup_subtitle">Export, import, automatic backup</string>
<!-- Widgets &amp; tiles (#69) -->
<string name="settings_section_widgets">Widgets &amp; tiles</string>
<string name="settings_widgets_hint">Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.</string>
<!-- Backup &amp; restore (#69) — the rows themselves reuse the calendars_* strings -->
<string name="settings_section_backup">Backup &amp; restore</string>
<!-- Views sub-headers (#69) -->
<string name="settings_views_all_header">All views</string>
<string name="settings_week_day_header">Week &amp; day</string>
<!-- Picker descriptions (#69) -->
<string name="settings_default_view_hint">The view Calendula opens on when you start it.</string>
<string name="settings_week_start_hint">The day every week begins on, in all views and widgets.</string>
<string name="settings_time_format_hint">How times are written throughout the app. Automatic follows your system setting.</string>
<string name="settings_past_events_hint">What the agenda does with events that have already ended.</string>
<string name="settings_dynamic_color_summary">Take the app\'s colours from your wallpaper.</string>
<!-- Contact special dates (issue #15) -->
<string name="settings_section_special_dates">Contact special dates</string>

View File

@@ -0,0 +1,178 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class RecurrenceOccurrencesTest {
private fun date(year: Int, month: Int, day: Int) = LocalDate(year, month, day)
@Test
fun `daily rule starts at the event's own date`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 1),
).inOrder()
}
@Test
fun `interval multiplies the period`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily, interval = 3)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 2),
date(2026, 8, 5),
).inOrder()
}
@Test
fun `weekly without weekday picks repeats the start's weekday`() {
// 30 Jul 2026 is a Thursday.
val occurrences = SimpleRecurrence(RecurrenceFreq.Weekly)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 6),
date(2026, 8, 13),
).inOrder()
}
@Test
fun `weekly with picks fires on every chosen day, in weekday order`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 4)
// Thursday start: the Monday of that week is already past, so the first
// hit is Friday 31 Jul, then Mon/Fri of the following weeks.
assertThat(occurrences).containsExactly(
date(2026, 7, 31),
date(2026, 8, 3),
date(2026, 8, 7),
date(2026, 8, 10),
).inOrder()
}
@Test
fun `weekly interval skips whole weeks, counted from Monday`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
interval = 2,
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.THURSDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
// Start week (27 Jul2 Aug) contributes only its Thursday; the next block
// is two weeks on, so 10 Aug and 13 Aug — not 3 Aug.
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 10),
date(2026, 8, 13),
).inOrder()
}
@Test
fun `monthly skips months without the start day rather than clamping`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Monthly)
.upcomingOccurrences(date(2026, 1, 31), limit = 4)
// February, April and June have no 31st — the rule passes them by.
assertThat(occurrences).containsExactly(
date(2026, 1, 31),
date(2026, 3, 31),
date(2026, 5, 31),
date(2026, 7, 31),
).inOrder()
}
@Test
fun `yearly on 29 February only lands in leap years`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Yearly)
.upcomingOccurrences(date(2024, 2, 29), limit = 3)
assertThat(occurrences).containsExactly(
date(2024, 2, 29),
date(2028, 2, 29),
date(2032, 2, 29),
).inOrder()
}
@Test
fun `count limits the series and skipped periods don't consume one`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Monthly,
end = RecurrenceEnd.Count(3),
).upcomingOccurrences(date(2026, 1, 31), limit = 10)
assertThat(occurrences).containsExactly(
date(2026, 1, 31),
date(2026, 3, 31),
date(2026, 5, 31),
).inOrder()
}
@Test
fun `until is inclusive of its own date`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(date(2026, 8, 1)),
).upcomingOccurrences(date(2026, 7, 30), limit = 10)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 1),
).inOrder()
}
@Test
fun `an until before the start yields nothing instead of spinning`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(date(2026, 7, 1)),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).isEmpty()
}
@Test
fun `a run inside one year needs no year, one that leaves it does`() {
val start = date(2026, 7, 30)
val withinYear = SimpleRecurrence(RecurrenceFreq.Daily).upcomingOccurrences(start, limit = 3)
assertThat(occurrencesSpanYears(withinYear, start)).isFalse()
// Yearly: same day and month every time, so the year is the only thing
// telling the three dates apart.
val yearly = SimpleRecurrence(RecurrenceFreq.Yearly).upcomingOccurrences(start, limit = 3)
assertThat(occurrencesSpanYears(yearly, start)).isTrue()
// Monthly rolling past December.
val newYearStart = date(2026, 11, 30)
val overflowing = SimpleRecurrence(RecurrenceFreq.Monthly)
.upcomingOccurrences(newYearStart, limit = 3)
assertThat(overflowing).containsExactly(
date(2026, 11, 30),
date(2026, 12, 30),
date(2027, 1, 30),
).inOrder()
assertThat(occurrencesSpanYears(overflowing, newYearStart)).isTrue()
}
@Test
fun `limit and count are both respected, whichever is smaller`() {
val rule = SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Count(2))
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 5)).hasSize(2)
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 1)).hasSize(1)
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 0)).isEmpty()
}
}

View File

@@ -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