diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 1a1f1d5..4fed5ad 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -276,35 +276,36 @@ class SettingsPrefs @Inject constructor( } /** - * The default reminder lead time (minutes before start) prefilled on new - * **timed** events. `null` = no default reminder — the prior behaviour, kept - * as the factory default so existing users aren't surprised by reminders they - * never asked for. Stored as a string so "none" is distinct from a numeric - * value (and from an unset key, which is also "none"). Per-calendar overrides - * in [perCalendarReminderOverride] take precedence; all-day events instead use + * The default reminder lead times (minutes before start) prefilled on new + * **timed** events. The empty list = no default reminder — the prior + * behaviour, kept as the factory default so existing users aren't surprised by + * reminders they never asked for. Stored as a comma-joined list of minutes, or + * "none"/empty for no reminder (also the unset state). A legacy single value + * ("30") parses transparently to a one-element list. Per-calendar overrides in + * [perCalendarReminderOverride] take precedence; all-day events instead use * [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder]. */ - val defaultReminderMinutes: Flow = store.data.map { prefs -> - prefs[DEFAULT_REMINDER_KEY].toReminderMinutes() + val defaultReminderMinutes: Flow> = store.data.map { prefs -> + prefs[DEFAULT_REMINDER_KEY].toReminderList() } - suspend fun setDefaultReminderMinutes(minutes: Int?) { - store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE } + suspend fun setDefaultReminderMinutes(minutes: List) { + store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() } } /** - * The default reminder lead time prefilled on new **all-day** events, in + * The default reminder lead times prefilled on new **all-day** events, in * minutes before the start of the day. All-day events want day-scale lead * times ("1 day before"), so they have their own default rather than reusing - * the timed one. `null` = no default. Per-calendar overrides do **not** apply - * to all-day events — they always use this global value. + * the timed one. Empty list = no default. Per-calendar overrides do **not** + * apply to all-day events — they always use this global value. */ - val defaultAllDayReminderMinutes: Flow = store.data.map { prefs -> - prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes() + val defaultAllDayReminderMinutes: Flow> = store.data.map { prefs -> + prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList() } - suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) { - store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE } + suspend fun setDefaultAllDayReminderMinutes(minutes: List) { + store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() } } /** @@ -341,13 +342,13 @@ class SettingsPrefs @Inject constructor( /** * Per-calendar overrides of [defaultReminderMinutes] for **timed** events, * keyed by calendar id. A calendar **present** in the map overrides the global - * timed default for its new events: a `null` value means "no reminder", an int - * means that lead time. A calendar **absent** from the map inherits the global - * default. Serialised as `id=value;id=value`, with `none` for an explicit - * no-reminder override. (All-day events ignore this and use - * [defaultAllDayReminderMinutes].) + * timed default for its new events: an **empty** list means "no reminder", a + * non-empty list means those lead times. A calendar **absent** from the map + * inherits the global default. Serialised as `id=value;id=value`, where a value + * is a comma-joined minute list or `none` for an explicit no-reminder override. + * (All-day events ignore this and use [defaultAllDayReminderMinutes].) */ - val perCalendarReminderOverride: Flow> = store.data.map { prefs -> + val perCalendarReminderOverride: Flow>> = store.data.map { prefs -> parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]) } @@ -364,7 +365,7 @@ class SettingsPrefs @Inject constructor( * events, with the same semantics as [perCalendarReminderOverride] (absent = * inherit the global all-day default; present null = no reminder). */ - val perCalendarAllDayReminderOverride: Flow> = store.data.map { prefs -> + val perCalendarAllDayReminderOverride: Flow>> = store.data.map { prefs -> parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]) } @@ -495,43 +496,46 @@ sealed interface CalendarReminderOverride { data object Inherit : CalendarReminderOverride /** Explicit "no reminder" for this calendar, regardless of the global default. */ data object None : CalendarReminderOverride - /** A specific lead time in minutes before the event start. */ - data class Minutes(val minutes: Int) : CalendarReminderOverride + /** Specific lead times in minutes before the event start (non-empty). */ + data class Minutes(val minutes: List) : CalendarReminderOverride } /** - * The lead time to prefill on a new event: the matching per-calendar override + * The lead times to prefill on a new event: the matching per-calendar override * if [calendarId] has one for this event kind, otherwise the global default for * that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed - * events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so - * it can be unit-tested. + * events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder. + * Pure so it can be unit-tested. */ fun resolveDefaultReminder( - timedGlobal: Int?, - allDayGlobal: Int?, - timedOverrides: Map, - allDayOverrides: Map, + timedGlobal: List, + allDayGlobal: List, + timedOverrides: Map>, + allDayOverrides: Map>, calendarId: Long?, isAllDay: Boolean, -): Int? { +): List { val overrides = if (isAllDay) allDayOverrides else timedOverrides val global = if (isAllDay) allDayGlobal else timedGlobal return if (calendarId != null && overrides.containsKey(calendarId)) { - overrides[calendarId] + overrides.getValue(calendarId) } else { global } } -/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */ -private fun MutableMap.applyOverride( +/** + * Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the + * key; [None] and an empty [Minutes] both store the empty list). + */ +private fun MutableMap>.applyOverride( calendarId: Long, override: CalendarReminderOverride, ) { when (override) { CalendarReminderOverride.Inherit -> remove(calendarId) - CalendarReminderOverride.None -> put(calendarId, null) - is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes) + CalendarReminderOverride.None -> put(calendarId, emptyList()) + is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders()) } } @@ -557,24 +561,36 @@ private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) { private const val NONE = "none" private const val ENTRY_SEP = ";" private const val KEY_VALUE_SEP = "=" +private const val LIST_SEP = "," -private fun String?.toReminderMinutes(): Int? = when (this) { - null, "", NONE -> null - else -> toIntOrNull() +/** Distinct, ascending lead times — the canonical form stored and resolved. */ +private fun List.normalizeReminders(): List = distinct().sorted() + +/** + * Parse a stored reminder value into lead times. `null`/empty/"none" → empty + * list; a comma-joined list → its minutes; a legacy single value ("30") → a + * one-element list. Non-numeric parts are dropped defensively. + */ +private fun String?.toReminderList(): List = when { + this == null || isEmpty() || this == NONE -> emptyList() + else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders() } -private fun parseReminderOverrides(stored: String?): Map { +/** Serialise lead times for storage: "none" when empty, else comma-joined. */ +private fun List.toStoredReminders(): String = + if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() } + +private fun parseReminderOverrides(stored: String?): Map> { if (stored.isNullOrBlank()) return emptyMap() return stored.split(ENTRY_SEP).mapNotNull { entry -> val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null val id = parts[0].toLongOrNull() ?: return@mapNotNull null - val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null - id to value + id to parts[1].toReminderList() }.toMap() } -private fun serializeReminderOverrides(map: Map): String = - map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" } +private fun serializeReminderOverrides(map: Map>): String = + map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" } private inline fun > String?.toEnum(default: E): E = this?.let { stored -> enumValues().firstOrNull { it.name == stored } } ?: default diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index ce6abae..847b5a1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Checkbox import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect 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.Alignment @@ -111,10 +113,15 @@ fun OptionPicker( } /** - * Reminder-default picker, full-screen: the grouped list (with an optional "Use - * default reminder" row and a "None" row), the [presets] as lead-time rows, and - * a "Custom" row that expands an inline number field plus a segmented unit - * selector. Returns the choice as a [CalendarReminderOverride]. + * Reminder-default picker, full-screen and **multi-select**: each [presets] + * lead time (plus any chosen custom value) is a checkbox row that toggles + * independently, so a default can carry several reminders ("1 week before" *and* + * "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use + * default reminder" row above the list; choosing it defers to the global default + * and clears the checked times. With nothing checked and inherit off, the choice + * is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" 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. */ @Composable fun ReminderDefaultPicker( @@ -125,57 +132,74 @@ fun ReminderDefaultPicker( onSelect: (CalendarReminderOverride) -> Unit, onDismiss: () -> Unit, ) { - val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes - val customSelected = selectedMinutes != null && selectedMinutes !in presets - val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected }) + // Optimistic local state: the chosen override is authoritative while the + // picker is open, so quick successive toggles compose on each other instead + // of racing the round-trip through the settings flow (which would drop a + // toggle made before the previous write echoes back). Seeded once on open; + // the picker leaves composition on dismiss, so reopening re-reads [selected]. + var current by remember { mutableStateOf(selected) } + val inherits = current is CalendarReminderOverride.Inherit + val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty() + // Presets plus any selected custom (non-preset) values, each as its own row. + val rows = presets + selectedMinutes.filter { it !in presets }.sorted() var customExpanded by rememberSaveable { mutableStateOf(false) } - var amountText by rememberSaveable { mutableStateOf(seed.first) } - var unit by rememberSaveable { mutableStateOf(seed.second) } + var amountText by rememberSaveable { mutableStateOf("") } + var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) } - val options = buildList { - if (allowInherit) add(CalendarReminderOverride.Inherit) - add(CalendarReminderOverride.None) - presets.forEach { add(CalendarReminderOverride.Minutes(it)) } + fun apply(override: CalendarReminderOverride) { + current = override + onSelect(override) } - val rowCount = options.size + 1 // + the custom row + // Emit the override for a new set of minutes: empty collapses to None, which + // (with inherit unchosen) reads as an explicit "no reminder". + fun emit(minutes: List) { + val norm = minutes.distinct().sorted() + apply( + if (norm.isEmpty()) { + CalendarReminderOverride.None + } else { + CalendarReminderOverride.Minutes(norm) + }, + ) + } + fun toggle(minute: Int) = + emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute) FullScreenPicker(title = title, onDismiss = onDismiss) { - options.forEachIndexed { index, option -> - val isSelected = option == selected + // Exclusive "use default" choice (per-calendar only): its own group above + // the multi-select list, since inheriting is mutually exclusive with + // picking specific times. + if (allowInherit) { GroupedRow( - title = reminderOverrideLabel(option), - position = positionOf(index, rowCount), - selected = isSelected, - trailing = if (isSelected) { + title = stringResource(R.string.reminder_use_default), + position = Position.Alone, + selected = inherits, + trailing = if (inherits) { { SelectedCheck() } } else { null }, - onClick = { - onSelect(option) - onDismiss() - }, + onClick = { apply(CalendarReminderOverride.Inherit) }, + ) + Spacer(Modifier.height(24.dp)) + } + val rowCount = rows.size + 1 // + the custom row + rows.forEachIndexed { index, minute -> + val checked = minute in selectedMinutes + GroupedRow( + title = reminderLeadTimeLabel(minute), + position = positionOf(index, rowCount), + selected = checked, + trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) }, + onClick = { toggle(minute) }, ) } // When expanded, the Custom row connects downward into the editor card // so the two read as one grouped container (the per-calendar pattern). GroupedRow( - title = if (customSelected) { - stringResource( - R.string.reminder_custom_with_value, - reminderLeadTimeLabel(selectedMinutes!!), - ) - } else { - stringResource(R.string.event_edit_reminder_custom) - }, - position = if (customExpanded) Position.Top else positionOf(options.size, rowCount), - selected = customSelected, - trailing = if (customSelected) { - { SelectedCheck() } - } else { - null - }, + title = stringResource(R.string.event_edit_reminder_custom), + position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount), onClick = { customExpanded = !customExpanded }, ) AnimatedVisibility( @@ -189,8 +213,9 @@ fun ReminderDefaultPicker( unit = unit, onUnitChange = { unit = it }, onConfirm = { minutes -> - onSelect(CalendarReminderOverride.Minutes(minutes)) - onDismiss() + emit(selectedMinutes + minutes) + amountText = "" + customExpanded = false }, ) } @@ -428,18 +453,3 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) { is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days) } -@Composable -private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) { - CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) - CalendarReminderOverride.None -> stringResource(R.string.reminder_none) - is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes) -} - -/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */ -private fun decomposeReminder(minutes: Int?): Pair = when { - minutes == null -> "" to ReminderUnit.Minutes - minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks - minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days - minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours - else -> minutes.toString() to ReminderUnit.Minutes -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 50a2f7d..7190beb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor( ) private data class ReminderDefaults( - val timed: Int?, - val allDay: Int?, - val timedOverrides: Map, - val allDayOverrides: Map, + val timed: List, + val allDay: List, + val timedOverrides: Map>, + val allDayOverrides: Map>, ) private data class ExternalInputs( @@ -260,7 +260,7 @@ class EventEditViewModel @Inject constructor( // Re-check after suspending: bail if the form closed or the user edited. val form = _form.value ?: return@launch if (_editTarget.value != null || _remindersTouched.value) return@launch - val default = resolveDefaultReminder( + val reminders = resolveDefaultReminder( timedGlobal = defaults.timed, allDayGlobal = defaults.allDay, timedOverrides = defaults.timedOverrides, @@ -268,7 +268,6 @@ class EventEditViewModel @Inject constructor( calendarId = targetId, isAllDay = form.isAllDay, ) - val reminders = listOfNotNull(default) _form.value = form.copy(reminders = reminders) // Surface the section so an auto-applied default is visible and // removable, even when Reminders isn't a default-shown field. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index d3fc450..cbc5048 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -969,7 +969,7 @@ private fun NotificationsScreen( presets = REMINDER_PRESETS, selected = state.defaultReminderMinutes.toReminderChoice(), allowInherit = false, - onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) }, + onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) }, onDismiss = { showDefaultReminder = false }, ) } @@ -979,7 +979,7 @@ private fun NotificationsScreen( presets = ALLDAY_REMINDER_PRESETS, selected = state.defaultAllDayReminderMinutes.toReminderChoice(), allowInherit = false, - onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) }, + onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) }, onDismiss = { showAllDayReminder = false }, ) } @@ -1028,13 +1028,13 @@ private fun NotificationsScreen( /** Which calendar + event kind a per-calendar reminder-override dialog targets. */ private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean) -/** A global default (null = none) as a picker choice for selection highlighting. */ -private fun Int?.toReminderChoice(): CalendarReminderOverride = - if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this) +/** A global default (empty = none) as a picker choice for selection highlighting. */ +private fun List.toReminderChoice(): CalendarReminderOverride = + if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this) /** A picked choice as global-default minutes (Inherit isn't offered for globals). */ -private fun CalendarReminderOverride.toMinutesOrNull(): Int? = - (this as? CalendarReminderOverride.Minutes)?.minutes +private fun CalendarReminderOverride.toMinutesList(): List = + (this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList() /** * Whether Calendula is exempt from battery optimisation, re-read on every @@ -1109,27 +1109,33 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String { } /** The stored override for [calendarId], as a picker choice (absent → inherit). */ -private fun Map.choiceFor(calendarId: Long): CalendarReminderOverride = when { +private fun Map>.choiceFor(calendarId: Long): CalendarReminderOverride = when { !containsKey(calendarId) -> CalendarReminderOverride.Inherit - this[calendarId] == null -> CalendarReminderOverride.None - else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!) + this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None + else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)) } -/** Label for a global-default choice: null → "None", else the lead time. */ +/** Label for a global-default choice: empty → "None", else the lead times joined. */ @Composable -private fun reminderChoiceLabel(minutes: Int?): String = - if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes) +private fun reminderChoiceLabel(minutes: List): String { + if (minutes.isEmpty()) return stringResource(R.string.reminder_none) + // reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather + // than a (non-composable) joinToString transform. + val parts = ArrayList(minutes.size) + for (m in minutes) parts.add(reminderLeadTimeLabel(m)) + return parts.joinToString(", ") +} /** Row summary for a calendar: its override, or the inherited global default. */ @Composable private fun calendarOverrideSummary( choice: CalendarReminderOverride, - globalDefault: Int?, + globalDefault: List, ): String = when (choice) { CalendarReminderOverride.Inherit -> stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault)) CalendarReminderOverride.None -> stringResource(R.string.reminder_none) - is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes) + is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes) } // --------------------------------------------------------------------------- diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 52c23ae..4ad49a7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -44,24 +44,24 @@ data class SettingsUiState( /** Whether Calendula posts reminder notifications (v1.4). */ val remindersEnabled: Boolean = true, /** - * The default reminder lead time (minutes) prefilled on new timed events; - * null = no default reminder. Per-calendar overrides take precedence. + * The default reminder lead times (minutes) prefilled on new timed events; + * empty = no default reminder. Per-calendar overrides take precedence. */ - val defaultReminderMinutes: Int? = null, - /** The default reminder lead time prefilled on new all-day events; null = none. */ - val defaultAllDayReminderMinutes: Int? = null, + val defaultReminderMinutes: List = emptyList(), + /** The default reminder lead times prefilled on new all-day events; empty = none. */ + val defaultAllDayReminderMinutes: List = emptyList(), /** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */ val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME, /** How long the notification "Snooze" action defers a reminder; default 10 min. */ val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES, /** * Per-calendar overrides of [defaultReminderMinutes] for timed events: a - * calendar present in the map overrides the global default (null value = no + * calendar present in the map overrides the global default (empty value = no * reminder); absent = inherit the global default. */ - val perCalendarReminderOverride: Map = emptyMap(), + val perCalendarReminderOverride: Map> = emptyMap(), /** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */ - val perCalendarAllDayReminderOverride: Map = emptyMap(), + val perCalendarAllDayReminderOverride: Map> = emptyMap(), /** Writable calendars, shown as per-calendar reminder-override rows. */ val writableCalendars: List = emptyList(), /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 557ab21..ae33454 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -139,15 +139,15 @@ class SettingsViewModel @Inject constructor( private data class ReminderDefaults( val allowColor: Boolean, - val defaultReminder: Int?, - val allDayReminder: Int?, + val defaultReminder: List, + val allDayReminder: List, val allDayReminderTime: Int, val snoozeMinutes: Int, ) private data class ReminderOverrides( - val timed: Map, - val allDay: Map, + val timed: Map>, + val allDay: Map>, val calendars: List, ) @@ -255,11 +255,11 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) } } - fun setDefaultReminderMinutes(minutes: Int?) { + fun setDefaultReminderMinutes(minutes: List) { viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) } } - fun setDefaultAllDayReminderMinutes(minutes: Int?) { + fun setDefaultAllDayReminderMinutes(minutes: List) { viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d6c9ab0..d4f6945 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -316,7 +316,6 @@ Keine Standard-Erinnerung verwenden Anzahl - Benutzerdefiniert (%1$s) Übernehmen Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen. Standard (%1$s) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d121f90..1f96a98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -324,7 +324,6 @@ None Use default reminder Amount - Custom (%1$s) Set Per-calendar reminders Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index eec1075..4ae1c9b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -183,22 +183,38 @@ class SettingsPrefsTest { } @Test - fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest { + fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - assertThat(prefs.defaultReminderMinutes.first()).isNull() + assertThat(prefs.defaultReminderMinutes.first()).isEmpty() } @Test - fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest { + fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - prefs.setDefaultReminderMinutes(30) - assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30) - prefs.setDefaultReminderMinutes(null) - assertThat(prefs.defaultReminderMinutes.first()).isNull() + prefs.setDefaultReminderMinutes(listOf(30)) + assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30) + // Several lead times persist, normalised to distinct ascending order. + prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30)) + assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder() + prefs.setDefaultReminderMinutes(emptyList()) + assertThat(prefs.defaultReminderMinutes.first()).isEmpty() } @Test - fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest { + fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + // The pre-multi-reminder format stored a bare integer string. + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30" + m + } + assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30) + } + + @Test + fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) val prefs = SettingsPrefs(store) store.updateData { p -> @@ -206,7 +222,7 @@ class SettingsPrefsTest { m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon" m } - assertThat(prefs.defaultReminderMinutes.first()).isNull() + assertThat(prefs.defaultReminderMinutes.first()).isEmpty() } @Test @@ -214,16 +230,16 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(newDataStore(tempDir)) assertThat(prefs.perCalendarReminderOverride.first()).isEmpty() - prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) + prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080))) prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None) prefs.perCalendarReminderOverride.first().let { map -> - assertThat(map).containsExactly(7L, 15, 9L, null) + assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList()) } - // Inherit drops the override entirely (absent != null value). + // Inherit drops the override entirely (absent != an empty-list value). prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit) prefs.perCalendarReminderOverride.first().let { map -> - assertThat(map).containsExactly(7L, 15) + assertThat(map).containsExactly(7L, listOf(15, 10_080)) assertThat(map.containsKey(9L)).isFalse() } } @@ -231,48 +247,48 @@ class SettingsPrefsTest { @Test fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull() - prefs.setDefaultAllDayReminderMinutes(1_440) - assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440) - prefs.setDefaultAllDayReminderMinutes(null) - assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull() + assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty() + prefs.setDefaultAllDayReminderMinutes(listOf(1_440)) + assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440) + prefs.setDefaultAllDayReminderMinutes(emptyList()) + assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty() } @Test fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) - prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440)) - assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) - assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440) + prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15))) + prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440))) + assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15)) + assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440)) // Clearing the all-day override leaves the timed one untouched. prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit) assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty() - assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) + assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15)) } @Test fun `resolveDefaultReminder picks the kind-matching override or global`() { - val timed = mapOf(7L to 15, 9L to null) - val allDay = mapOf(7L to 2_880) + val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList()) + val allDay = mapOf(7L to listOf(2_880)) fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder( - timedGlobal = 30, - allDayGlobal = 1_440, + timedGlobal = listOf(30), + allDayGlobal = listOf(1_440), timedOverrides = timed, allDayOverrides = allDay, calendarId = calendarId, isAllDay = isAllDay, ) - // Timed: minutes override, explicit none, inherit global, no calendar. - assertThat(resolve(7L, isAllDay = false)).isEqualTo(15) - assertThat(resolve(9L, isAllDay = false)).isNull() - assertThat(resolve(5L, isAllDay = false)).isEqualTo(30) - assertThat(resolve(null, isAllDay = false)).isEqualTo(30) + // Timed: multi-value override, explicit none, inherit global, no calendar. + assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder() + assertThat(resolve(9L, isAllDay = false)).isEmpty() + assertThat(resolve(5L, isAllDay = false)).containsExactly(30) + assertThat(resolve(null, isAllDay = false)).containsExactly(30) // All-day: its own override wins; absent → all-day global; a timed-only // override (cal 9) does not bleed into all-day. - assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880) - assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440) - assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440) + assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880) + assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440) + assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440) } @Test