Compare commits

..

2 Commits

Author SHA1 Message Date
2ae4c818ba feat(reminders): allow multiple default reminders per calendar
Codeberg #14. Event-level multi-reminders already worked; this widens the
*defaults* layer from a single Int to a List<Int> so a calendar's default can
carry several lead times (e.g. a birthday calendar: one week before *and* on
the day).

- SettingsPrefs: global + per-calendar timed/all-day defaults become List<Int>;
  CalendarReminderOverride.Minutes(List<Int>); resolveDefaultReminder returns a
  list. Storage stays backward-compatible — a legacy single value ("30") parses
  to [30], comma-joined for multiples, "none"/empty for no reminder.
- ReminderDefaultPicker is now multi-select: M3 Checkbox rows over the existing
  grouped-tonal idiom, an exclusive "use default" group for per-calendar
  overrides, and a Custom row that adds an arbitrary lead time to the set.
  Optimistic local state so quick successive toggles don't race the settings
  flow round-trip.
- New events seed their reminder list from the resolved default.

Tests cover none/single/multiple round-trips, legacy single-value parsing, and
per-calendar list overrides. lint + test + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:19:44 +02:00
fb2a40114a Merge pull request 'release: v2.12.0 — past-event display + week-view title wrap' (!51) from release/v2.12.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 10m22s
Renovate / renovate (push) Successful in 1m9s
Reviewed-on: #51
2026-06-28 20:43:48 +00:00
9 changed files with 220 additions and 175 deletions

View File

@@ -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<Int?> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
val defaultReminderMinutes: Flow<List<Int>> = 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<Int>) {
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<Int?> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
val defaultAllDayReminderMinutes: Flow<List<Int>> = 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<Int>) {
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<Map<Long, Int?>> = store.data.map { prefs ->
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = 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<Map<Long, Int?>> = store.data.map { prefs ->
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = 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<Int>) : 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<Long, Int?>,
allDayOverrides: Map<Long, Int?>,
timedGlobal: List<Int>,
allDayGlobal: List<Int>,
timedOverrides: Map<Long, List<Int>>,
allDayOverrides: Map<Long, List<Int>>,
calendarId: Long?,
isAllDay: Boolean,
): Int? {
): List<Int> {
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<Long, Int?>.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<Long, List<Int>>.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<Int>.normalizeReminders(): List<Int> = 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<Int> = when {
this == null || isEmpty() || this == NONE -> emptyList()
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
}
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
/** Serialise lead times for storage: "none" when empty, else comma-joined. */
private fun List<Int>.toStoredReminders(): String =
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
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<Long, Int?>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -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 <T> 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<Int>) {
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<String, ReminderUnit> = 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
}

View File

@@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor(
)
private data class ReminderDefaults(
val timed: Int?,
val allDay: Int?,
val timedOverrides: Map<Long, Int?>,
val allDayOverrides: Map<Long, Int?>,
val timed: List<Int>,
val allDay: List<Int>,
val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>,
)
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.

View File

@@ -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<Int>.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<Int> =
(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<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
private fun Map<Long, List<Int>>.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<Int>): 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<String>(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<Int>,
): 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)
}
// ---------------------------------------------------------------------------

View File

@@ -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<Int> = emptyList(),
/** The default reminder lead times prefilled on new all-day events; empty = none. */
val defaultAllDayReminderMinutes: List<Int> = 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<Long, Int?> = emptyMap(),
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(),
/**

View File

@@ -139,15 +139,15 @@ class SettingsViewModel @Inject constructor(
private data class ReminderDefaults(
val allowColor: Boolean,
val defaultReminder: Int?,
val allDayReminder: Int?,
val defaultReminder: List<Int>,
val allDayReminder: List<Int>,
val allDayReminderTime: Int,
val snoozeMinutes: Int,
)
private data class ReminderOverrides(
val timed: Map<Long, Int?>,
val allDay: Map<Long, Int?>,
val timed: Map<Long, List<Int>>,
val allDay: Map<Long, List<Int>>,
val calendars: List<CalendarSource>,
)
@@ -255,11 +255,11 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
}
fun setDefaultReminderMinutes(minutes: Int?) {
fun setDefaultReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
}
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
}

View File

@@ -316,7 +316,6 @@
<string name="reminder_none">Keine</string>
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
<string name="reminder_custom_amount">Anzahl</string>
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
<string name="reminder_custom_set">Übernehmen</string>
<string name="settings_calendar_reminders_hint">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.</string>
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>

View File

@@ -324,7 +324,6 @@
<string name="reminder_none">None</string>
<string name="reminder_use_default">Use default reminder</string>
<string name="reminder_custom_amount">Amount</string>
<string name="reminder_custom_with_value">Custom (%1$s)</string>
<string name="reminder_custom_set">Set</string>
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>

View File

@@ -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<Int>())
}
// 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<Int>())
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