Merge branch 'feat/per-calendar-multi-reminders' into release/v2.13.0

This commit is contained in:
2026-07-01 00:41:12 +02:00
10 changed files with 240 additions and 175 deletions

View File

@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Set more than one default reminder per calendar. A calendar's default
reminders — and the global defaults under Settings → Notifications — can now
hold several lead times instead of just one, so new events can start with, say,
a reminder a week before *and* one on the day. The reminder pickers are now
multi-select; per-calendar overrides can still inherit the global default or
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
### Fixed
- Editing a single occurrence of a recurring event works again. Choosing **Only
this event** and saving a change to one event in a repeating series silently
did nothing — the change was rejected and the edit form simply reappeared with
nothing applied. The edited occurrence is now stored correctly, so the change
lands on just that one event and leaves the rest of the series untouched
([#16]).
## [2.12.0] — 2026-06-28 ## [2.12.0] — 2026-06-28
### Added ### Added
@@ -729,3 +747,5 @@ automatically, with zero telemetry and no internet permission.
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10 [#10]: https://codeberg.org/jlmakiola/calendula/issues/10
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12 [#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13 [#13]: https://codeberg.org/jlmakiola/calendula/issues/13
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16

View File

@@ -276,35 +276,36 @@ class SettingsPrefs @Inject constructor(
} }
/** /**
* The default reminder lead time (minutes before start) prefilled on new * The default reminder lead times (minutes before start) prefilled on new
* **timed** events. `null` = no default reminder — the prior behaviour, kept * **timed** events. The empty list = no default reminder — the prior
* as the factory default so existing users aren't surprised by reminders they * behaviour, kept as the factory default so existing users aren't surprised by
* never asked for. Stored as a string so "none" is distinct from a numeric * reminders they never asked for. Stored as a comma-joined list of minutes, or
* value (and from an unset key, which is also "none"). Per-calendar overrides * "none"/empty for no reminder (also the unset state). A legacy single value
* in [perCalendarReminderOverride] take precedence; all-day events instead use * ("30") parses transparently to a one-element list. Per-calendar overrides in
* [perCalendarReminderOverride] take precedence; all-day events instead use
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder]. * [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
*/ */
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs -> val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes() prefs[DEFAULT_REMINDER_KEY].toReminderList()
} }
suspend fun setDefaultReminderMinutes(minutes: Int?) { suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE } 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 * 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 * 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 * the timed one. Empty list = no default. Per-calendar overrides do **not**
* to all-day events — they always use this global value. * apply to all-day events — they always use this global value.
*/ */
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs -> val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes() prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
} }
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) { suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE } 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, * Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
* keyed by calendar id. A calendar **present** in the map overrides the global * 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 * timed default for its new events: an **empty** list means "no reminder", a
* means that lead time. A calendar **absent** from the map inherits the global * non-empty list means those lead times. A calendar **absent** from the map
* default. Serialised as `id=value;id=value`, with `none` for an explicit * inherits the global default. Serialised as `id=value;id=value`, where a value
* no-reminder override. (All-day events ignore this and use * is a comma-joined minute list or `none` for an explicit no-reminder override.
* [defaultAllDayReminderMinutes].) * (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]) parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
} }
@@ -364,7 +365,7 @@ class SettingsPrefs @Inject constructor(
* events, with the same semantics as [perCalendarReminderOverride] (absent = * events, with the same semantics as [perCalendarReminderOverride] (absent =
* inherit the global all-day default; present null = no reminder). * 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]) parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
} }
@@ -495,43 +496,46 @@ sealed interface CalendarReminderOverride {
data object Inherit : CalendarReminderOverride data object Inherit : CalendarReminderOverride
/** Explicit "no reminder" for this calendar, regardless of the global default. */ /** Explicit "no reminder" for this calendar, regardless of the global default. */
data object None : CalendarReminderOverride data object None : CalendarReminderOverride
/** A specific lead time in minutes before the event start. */ /** Specific lead times in minutes before the event start (non-empty). */
data class Minutes(val minutes: Int) : CalendarReminderOverride 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 * if [calendarId] has one for this event kind, otherwise the global default for
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed * that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so * events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
* it can be unit-tested. * Pure so it can be unit-tested.
*/ */
fun resolveDefaultReminder( fun resolveDefaultReminder(
timedGlobal: Int?, timedGlobal: List<Int>,
allDayGlobal: Int?, allDayGlobal: List<Int>,
timedOverrides: Map<Long, Int?>, timedOverrides: Map<Long, List<Int>>,
allDayOverrides: Map<Long, Int?>, allDayOverrides: Map<Long, List<Int>>,
calendarId: Long?, calendarId: Long?,
isAllDay: Boolean, isAllDay: Boolean,
): Int? { ): List<Int> {
val overrides = if (isAllDay) allDayOverrides else timedOverrides val overrides = if (isAllDay) allDayOverrides else timedOverrides
val global = if (isAllDay) allDayGlobal else timedGlobal val global = if (isAllDay) allDayGlobal else timedGlobal
return if (calendarId != null && overrides.containsKey(calendarId)) { return if (calendarId != null && overrides.containsKey(calendarId)) {
overrides[calendarId] overrides.getValue(calendarId)
} else { } else {
global 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, calendarId: Long,
override: CalendarReminderOverride, override: CalendarReminderOverride,
) { ) {
when (override) { when (override) {
CalendarReminderOverride.Inherit -> remove(calendarId) CalendarReminderOverride.Inherit -> remove(calendarId)
CalendarReminderOverride.None -> put(calendarId, null) CalendarReminderOverride.None -> put(calendarId, emptyList())
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes) 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 NONE = "none"
private const val ENTRY_SEP = ";" private const val ENTRY_SEP = ";"
private const val KEY_VALUE_SEP = "=" private const val KEY_VALUE_SEP = "="
private const val LIST_SEP = ","
private fun String?.toReminderMinutes(): Int? = when (this) { /** Distinct, ascending lead times — the canonical form stored and resolved. */
null, "", NONE -> null private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
else -> toIntOrNull()
/**
* 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() if (stored.isNullOrBlank()) return emptyMap()
return stored.split(ENTRY_SEP).mapNotNull { entry -> return stored.split(ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: 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 parts[1].toReminderList()
id to value
}.toMap() }.toMap()
} }
private fun serializeReminderOverrides(map: Map<Long, Int?>): String = private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" } 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 = private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -111,10 +113,15 @@ fun <T> OptionPicker(
} }
/** /**
* Reminder-default picker, full-screen: the grouped list (with an optional "Use * Reminder-default picker, full-screen and **multi-select**: each [presets]
* default reminder" row and a "None" row), the [presets] as lead-time rows, and * lead time (plus any chosen custom value) is a checkbox row that toggles
* a "Custom" row that expands an inline number field plus a segmented unit * independently, so a default can carry several reminders ("1 week before" *and*
* selector. Returns the choice as a [CalendarReminderOverride]. * "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 @Composable
fun ReminderDefaultPicker( fun ReminderDefaultPicker(
@@ -125,57 +132,74 @@ fun ReminderDefaultPicker(
onSelect: (CalendarReminderOverride) -> Unit, onSelect: (CalendarReminderOverride) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes // Optimistic local state: the chosen override is authoritative while the
val customSelected = selectedMinutes != null && selectedMinutes !in presets // picker is open, so quick successive toggles compose on each other instead
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected }) // 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 customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.first) } var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(seed.second) } var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
val options = buildList { fun apply(override: CalendarReminderOverride) {
if (allowInherit) add(CalendarReminderOverride.Inherit) current = override
add(CalendarReminderOverride.None) onSelect(override)
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
} }
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) { FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEachIndexed { index, option -> // Exclusive "use default" choice (per-calendar only): its own group above
val isSelected = option == selected // the multi-select list, since inheriting is mutually exclusive with
// picking specific times.
if (allowInherit) {
GroupedRow( GroupedRow(
title = reminderOverrideLabel(option), title = stringResource(R.string.reminder_use_default),
position = positionOf(index, rowCount), position = Position.Alone,
selected = isSelected, selected = inherits,
trailing = if (isSelected) { trailing = if (inherits) {
{ SelectedCheck() } { SelectedCheck() }
} else { } else {
null null
}, },
onClick = { onClick = { apply(CalendarReminderOverride.Inherit) },
onSelect(option) )
onDismiss() 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 // When expanded, the Custom row connects downward into the editor card
// so the two read as one grouped container (the per-calendar pattern). // so the two read as one grouped container (the per-calendar pattern).
GroupedRow( GroupedRow(
title = if (customSelected) { title = stringResource(R.string.event_edit_reminder_custom),
stringResource( position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
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
},
onClick = { customExpanded = !customExpanded }, onClick = { customExpanded = !customExpanded },
) )
AnimatedVisibility( AnimatedVisibility(
@@ -189,8 +213,9 @@ fun ReminderDefaultPicker(
unit = unit, unit = unit,
onUnitChange = { unit = it }, onUnitChange = { unit = it },
onConfirm = { minutes -> onConfirm = { minutes ->
onSelect(CalendarReminderOverride.Minutes(minutes)) emit(selectedMinutes + minutes)
onDismiss() 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) 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( private data class ReminderDefaults(
val timed: Int?, val timed: List<Int>,
val allDay: Int?, val allDay: List<Int>,
val timedOverrides: Map<Long, Int?>, val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, Int?>, val allDayOverrides: Map<Long, List<Int>>,
) )
private data class ExternalInputs( 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. // Re-check after suspending: bail if the form closed or the user edited.
val form = _form.value ?: return@launch val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch if (_editTarget.value != null || _remindersTouched.value) return@launch
val default = resolveDefaultReminder( val reminders = resolveDefaultReminder(
timedGlobal = defaults.timed, timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay, allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides, timedOverrides = defaults.timedOverrides,
@@ -268,7 +268,6 @@ class EventEditViewModel @Inject constructor(
calendarId = targetId, calendarId = targetId,
isAllDay = form.isAllDay, isAllDay = form.isAllDay,
) )
val reminders = listOfNotNull(default)
_form.value = form.copy(reminders = reminders) _form.value = form.copy(reminders = reminders)
// Surface the section so an auto-applied default is visible and // Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field. // removable, even when Reminders isn't a default-shown field.

View File

@@ -969,7 +969,7 @@ private fun NotificationsScreen(
presets = REMINDER_PRESETS, presets = REMINDER_PRESETS,
selected = state.defaultReminderMinutes.toReminderChoice(), selected = state.defaultReminderMinutes.toReminderChoice(),
allowInherit = false, allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) }, onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
onDismiss = { showDefaultReminder = false }, onDismiss = { showDefaultReminder = false },
) )
} }
@@ -979,7 +979,7 @@ private fun NotificationsScreen(
presets = ALLDAY_REMINDER_PRESETS, presets = ALLDAY_REMINDER_PRESETS,
selected = state.defaultAllDayReminderMinutes.toReminderChoice(), selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
allowInherit = false, allowInherit = false,
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) }, onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
onDismiss = { showAllDayReminder = false }, onDismiss = { showAllDayReminder = false },
) )
} }
@@ -1028,13 +1028,13 @@ private fun NotificationsScreen(
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */ /** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean) private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
/** A global default (null = none) as a picker choice for selection highlighting. */ /** A global default (empty = none) as a picker choice for selection highlighting. */
private fun Int?.toReminderChoice(): CalendarReminderOverride = private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this) if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */ /** A picked choice as global-default minutes (Inherit isn't offered for globals). */
private fun CalendarReminderOverride.toMinutesOrNull(): Int? = private fun CalendarReminderOverride.toMinutesList(): List<Int> =
(this as? CalendarReminderOverride.Minutes)?.minutes (this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
/** /**
* Whether Calendula is exempt from battery optimisation, re-read on every * 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). */ /** 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 !containsKey(calendarId) -> CalendarReminderOverride.Inherit
this[calendarId] == null -> CalendarReminderOverride.None this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!) 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 @Composable
private fun reminderChoiceLabel(minutes: Int?): String = private fun reminderChoiceLabel(minutes: List<Int>): String {
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes) 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. */ /** Row summary for a calendar: its override, or the inherited global default. */
@Composable @Composable
private fun calendarOverrideSummary( private fun calendarOverrideSummary(
choice: CalendarReminderOverride, choice: CalendarReminderOverride,
globalDefault: Int?, globalDefault: List<Int>,
): String = when (choice) { ): String = when (choice) {
CalendarReminderOverride.Inherit -> CalendarReminderOverride.Inherit ->
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault)) stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
CalendarReminderOverride.None -> stringResource(R.string.reminder_none) 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). */ /** Whether Calendula posts reminder notifications (v1.4). */
val remindersEnabled: Boolean = true, val remindersEnabled: Boolean = true,
/** /**
* The default reminder lead time (minutes) prefilled on new timed events; * The default reminder lead times (minutes) prefilled on new timed events;
* null = no default reminder. Per-calendar overrides take precedence. * empty = no default reminder. Per-calendar overrides take precedence.
*/ */
val defaultReminderMinutes: Int? = null, val defaultReminderMinutes: List<Int> = emptyList(),
/** The default reminder lead time prefilled on new all-day events; null = none. */ /** The default reminder lead times prefilled on new all-day events; empty = none. */
val defaultAllDayReminderMinutes: Int? = null, val defaultAllDayReminderMinutes: List<Int> = emptyList(),
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */ /** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME, val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
/** How long the notification "Snooze" action defers a reminder; default 10 min. */ /** How long the notification "Snooze" action defers a reminder; default 10 min. */
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES, val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
/** /**
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a * 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. * 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. */ /** 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. */ /** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(), val writableCalendars: List<CalendarSource> = emptyList(),
/** /**

View File

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

View File

@@ -316,7 +316,6 @@
<string name="reminder_none">Keine</string> <string name="reminder_none">Keine</string>
<string name="reminder_use_default">Standard-Erinnerung verwenden</string> <string name="reminder_use_default">Standard-Erinnerung verwenden</string>
<string name="reminder_custom_amount">Anzahl</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="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_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> <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_none">None</string>
<string name="reminder_use_default">Use default reminder</string> <string name="reminder_use_default">Use default reminder</string>
<string name="reminder_custom_amount">Amount</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="reminder_custom_set">Set</string>
<string name="settings_calendar_reminders_title">Per-calendar reminders</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> <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 @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)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultReminderMinutes.first()).isNull() assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
} }
@Test @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)) val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setDefaultReminderMinutes(30) prefs.setDefaultReminderMinutes(listOf(30))
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30) assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
prefs.setDefaultReminderMinutes(null) // Several lead times persist, normalised to distinct ascending order.
assertThat(prefs.defaultReminderMinutes.first()).isNull() 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 @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 store = newDataStore(tempDir)
val prefs = SettingsPrefs(store) val prefs = SettingsPrefs(store)
store.updateData { p -> store.updateData { p ->
@@ -206,7 +222,7 @@ class SettingsPrefsTest {
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon" m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
m m
} }
assertThat(prefs.defaultReminderMinutes.first()).isNull() assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
} }
@Test @Test
@@ -214,16 +230,16 @@ class SettingsPrefsTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty() 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.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
prefs.perCalendarReminderOverride.first().let { map -> 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.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
prefs.perCalendarReminderOverride.first().let { map -> prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15) assertThat(map).containsExactly(7L, listOf(15, 10_080))
assertThat(map.containsKey(9L)).isFalse() assertThat(map.containsKey(9L)).isFalse()
} }
} }
@@ -231,48 +247,48 @@ class SettingsPrefsTest {
@Test @Test
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest { fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull() assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
prefs.setDefaultAllDayReminderMinutes(1_440) prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440) assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
prefs.setDefaultAllDayReminderMinutes(null) prefs.setDefaultAllDayReminderMinutes(emptyList())
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull() assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
} }
@Test @Test
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest { fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440)) prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440) assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
// Clearing the all-day override leaves the timed one untouched. // Clearing the all-day override leaves the timed one untouched.
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit) prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty() assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
} }
@Test @Test
fun `resolveDefaultReminder picks the kind-matching override or global`() { fun `resolveDefaultReminder picks the kind-matching override or global`() {
val timed = mapOf(7L to 15, 9L to null) val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList<Int>())
val allDay = mapOf(7L to 2_880) val allDay = mapOf(7L to listOf(2_880))
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder( fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
timedGlobal = 30, timedGlobal = listOf(30),
allDayGlobal = 1_440, allDayGlobal = listOf(1_440),
timedOverrides = timed, timedOverrides = timed,
allDayOverrides = allDay, allDayOverrides = allDay,
calendarId = calendarId, calendarId = calendarId,
isAllDay = isAllDay, isAllDay = isAllDay,
) )
// Timed: minutes override, explicit none, inherit global, no calendar. // Timed: multi-value override, explicit none, inherit global, no calendar.
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15) assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder()
assertThat(resolve(9L, isAllDay = false)).isNull() assertThat(resolve(9L, isAllDay = false)).isEmpty()
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30) assertThat(resolve(5L, isAllDay = false)).containsExactly(30)
assertThat(resolve(null, isAllDay = false)).isEqualTo(30) assertThat(resolve(null, isAllDay = false)).containsExactly(30)
// All-day: its own override wins; absent → all-day global; a timed-only // All-day: its own override wins; absent → all-day global; a timed-only
// override (cal 9) does not bleed into all-day. // override (cal 9) does not bleed into all-day.
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880) assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880)
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440) assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440)
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440) assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440)
} }
@Test @Test