From 2ae4c818ba7d84296745ea44270bce0cae3e53d4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 30 Jun 2026 15:19:25 +0200 Subject: [PATCH 1/6] feat(reminders): allow multiple default reminders per calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codeberg #14. Event-level multi-reminders already worked; this widens the *defaults* layer from a single Int to a List 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; CalendarReminderOverride.Minutes(List); 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) --- .../calendula/data/prefs/SettingsPrefs.kt | 110 +++++++++------- .../calendula/ui/common/Picker.kt | 122 ++++++++++-------- .../calendula/ui/edit/EventEditViewModel.kt | 11 +- .../calendula/ui/settings/SettingsScreen.kt | 36 +++--- .../calendula/ui/settings/SettingsUiState.kt | 16 +-- .../ui/settings/SettingsViewModel.kt | 12 +- app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - .../calendula/data/prefs/SettingsPrefsTest.kt | 86 +++++++----- 9 files changed, 220 insertions(+), 175 deletions(-) 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 From c2d88e744ee5ece0ffbb7e740b2c04207e69fcf6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 30 Jun 2026 15:06:58 +0200 Subject: [PATCH 2/6] fix(edit): write occurrence exceptions with DURATION, not DTEND MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving "only this event" on a recurring event inserts a modified-occurrence exception at Events.CONTENT_EXCEPTION_URI. buildOccurrenceExceptionValues set the occurrence's end as DTEND, but the provider rejects that outright — CalendarProvider2.checkAllowedInException throws IllegalArgumentException: Exceptions can't overwrite dtend so the insert failed, the save surfaced as SaveUiState.Failed, and the edit screen reappeared with no change applied. "This and following" and "all events" never go through the exception insert, which is why only "only this event" broke (Codeberg #16, verified on a Pixel 10 / Android 16). An exception is a single instance whose end the provider derives from DTSTART + DURATION (clearing the inherited RRULE itself), so carry the length as DURATION and drop DTEND — the same shape AOSP Calendar/Etar use. Verified on-device: editing one occurrence now applies to just that occurrence and leaves the rest of the series untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/calendar/EventWriteMapper.kt | 12 +++++++++--- .../calendula/data/calendar/EventWriteMapperTest.kt | 9 +++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 0f50930..2c3928c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -118,8 +118,14 @@ internal fun buildEventUpdateValues( * provider clone the series row and apply these on top. Unlike the series * update there is no dirty check — the exception is a fresh row, so every * form-backed column is written (empty optionals as explicit NULLs, since the - * clone starts from the parent's values). An exception is a single event: - * DTEND, never RRULE/DURATION. + * clone starts from the parent's values). + * + * The occurrence's length travels as DURATION, never DTEND: the provider + * rejects DTEND on an exception outright (`CalendarProvider2`: + * "Exceptions can't overwrite dtend") and derives the instance end from + * DTSTART + DURATION itself, clearing the inherited RRULE in the process. This + * matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made + * "only this event" fail on-device (Codeberg #16). */ internal fun buildOccurrenceExceptionValues( form: EventForm, @@ -131,7 +137,7 @@ internal fun buildOccurrenceExceptionValues( put(CalendarContract.Events.TITLE, form.title.trim()) put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) put(CalendarContract.Events.DTSTART, times.dtStartMillis) - put(CalendarContract.Events.DTEND, times.dtEndMillis) + put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay)) put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 40aac33..f1be42b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -199,11 +199,12 @@ class EventWriteMapperTest { assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved") assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin") assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) - assertThat(values[CalendarContract.Events.DTEND]) - .isEqualTo(1_781_164_800_000L + 5_400_000L) - // A single occurrence never carries its own rule. + // The occurrence's length travels as DURATION, never DTEND — the provider + // rejects DTEND on an exception ("Exceptions can't overwrite dtend") and + // derives the end from DTSTART + DURATION, clearing the inherited RRULE. + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") + assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND) assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) - assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION) } @Test From f2fb3d62795c8e9860659b9d949ffe7e6063ca7c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 30 Jun 2026 16:18:47 +0200 Subject: [PATCH 3/6] feat(debug): mark debug builds with a distinct icon, label, and ribbon Make a debug install unmistakable next to the production app, Flutter-style: - src/debug resource overrides: "Calendula Debug" label and a burnt-orange launcher-icon background (production stays slate). The .debug applicationId suffix already lets both install side by side. - DebugRibbon: a "DEBUG" corner ribbon overlaid in MainActivity, gated on BuildConfig.DEBUG so release/releaseTest never show it. Enables the buildConfig feature for the flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 2 + app/src/debug/res/values/colors.xml | 10 ++++ app/src/debug/res/values/strings.xml | 10 ++++ .../jeanlucmakiola/calendula/MainActivity.kt | 33 +++++++----- .../calendula/ui/common/DebugRibbon.kt | 50 +++++++++++++++++++ 5 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 app/src/debug/res/values/colors.xml create mode 100644 app/src/debug/res/values/strings.xml create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DebugRibbon.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b20a171..72977e7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -90,6 +90,8 @@ android { buildFeatures { compose = true + // BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon). + buildConfig = true } // Don't embed AGP's dependency-metadata block in the APK signing block. It's diff --git a/app/src/debug/res/values/colors.xml b/app/src/debug/res/values/colors.xml new file mode 100644 index 0000000..87de5a9 --- /dev/null +++ b/app/src/debug/res/values/colors.xml @@ -0,0 +1,10 @@ + + + + #FFB23B00 + diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..5dad449 --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,10 @@ + + + + Calendula Debug + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index a2de42c..ede492a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -29,6 +30,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.DebugRibbon import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport @@ -101,19 +103,24 @@ class MainActivity : AppCompatActivity() { darkTheme = darkTheme, dynamicColor = settings.dynamicColor, ) { - CompositionLocalProvider( - LocalUse24HourFormat provides use24Hour, - LocalShowHourLines provides settings.showHourLines, - ) { - RootScreen( - modifier = Modifier.fillMaxSize(), - requestedDetailKey = requestedDetailKey, - onDetailKeyConsumed = { requestedDetailKey = null }, - widgetNavRequest = requestedNav, - onWidgetNavConsumed = { requestedNav = null }, - requestedImportUri = requestedImportUri, - onImportConsumed = { requestedImportUri = null }, - ) + Box(modifier = Modifier.fillMaxSize()) { + CompositionLocalProvider( + LocalUse24HourFormat provides use24Hour, + LocalShowHourLines provides settings.showHourLines, + ) { + RootScreen( + modifier = Modifier.fillMaxSize(), + requestedDetailKey = requestedDetailKey, + onDetailKeyConsumed = { requestedDetailKey = null }, + widgetNavRequest = requestedNav, + onWidgetNavConsumed = { requestedNav = null }, + requestedImportUri = requestedImportUri, + onImportConsumed = { requestedImportUri = null }, + ) + } + // A persistent corner marker so a debug build is never + // mistaken for the production app; compiled out of release. + if (BuildConfig.DEBUG) DebugRibbon() } pendingCrashReport?.let { report -> CrashReportDialog( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DebugRibbon.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DebugRibbon.kt new file mode 100644 index 0000000..3ca1381 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/DebugRibbon.kt @@ -0,0 +1,50 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex + +/** + * A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of + * the app. Deliberately a stark, un-themed marker (not a product component) so a + * debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the + * call site so it never reaches a release build. Non-interactive: it's a plain + * label with no pointer handler, so taps fall through to whatever is beneath it. + * + * Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box] + * so it overlays the UI. + */ +@Composable +fun BoxScope.DebugRibbon() { + Text( + text = "DEBUG", + color = Color.White, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.TopEnd) + .zIndex(1f) + // Push the band out so its midline crosses the very corner, then + // rotate it to the classic 45° ribbon. + .offset(x = 36.dp, y = 24.dp) + .rotate(45f) + .background(Color(0xFFB23B00)) + .width(140.dp) + .padding(vertical = 2.dp), + ) +} From 048f407ba1ff95be05cf0ef75d7a0b741803ad56 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 00:40:46 +0200 Subject: [PATCH 4/6] docs(changelog): record v2.13.0 unreleased changes Add an [Unreleased] section covering the two user-facing changes in release/v2.13.0: multiple default reminders per calendar (Codeberg #14, @moonj) and the fix for editing a single occurrence of a recurring event (Codeberg #16). The debug-build ribbon is dev-only and intentionally omitted. Define the [#14] and [#16] links. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab8105..61779f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added @@ -729,3 +747,5 @@ automatically, with zero telemetry and no internet permission. [#10]: https://codeberg.org/jlmakiola/calendula/issues/10 [#12]: https://codeberg.org/jlmakiola/calendula/issues/12 [#13]: https://codeberg.org/jlmakiola/calendula/issues/13 +[#14]: https://codeberg.org/jlmakiola/calendula/issues/14 +[#16]: https://codeberg.org/jlmakiola/calendula/issues/16 From 5d887524b14aea5fc07cda0cce038a6430923e51 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 00:43:56 +0200 Subject: [PATCH 5/6] fix(reminders): suppress notifications for disabled calendars Reminders fired for every due CalendarAlerts row regardless of whether the event's calendar had been disabled in-app, even though disabled calendars are hidden from every other surface. Carry the calendar id through the alert and filter disabled calendars before posting, while still marking all due alerts fired so the provider stops re-broadcasting the suppressed ones. Closes #17 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +++++ .../data/reminders/EventReminderReceiver.kt | 10 +++++++++- .../data/reminders/ReminderActionReceiver.kt | 3 +++ .../calendula/data/reminders/ReminderAlertStore.kt | 13 ++++++++----- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61779f7..6d973ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]). ### Fixed +- Disabled calendars no longer notify. Reminders for events in a calendar you + have disabled (Settings → Calendars) are now suppressed instead of still + popping up — matching how a disabled calendar's events already stay hidden + everywhere else in the app ([#17]). - 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 @@ -749,3 +753,4 @@ automatically, with zero telemetry and no internet permission. [#13]: https://codeberg.org/jlmakiola/calendula/issues/13 [#14]: https://codeberg.org/jlmakiola/calendula/issues/14 [#16]: https://codeberg.org/jlmakiola/calendula/issues/16 +[#17]: https://codeberg.org/jlmakiola/calendula/issues/17 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt index 71af6a3..62078c9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt @@ -8,6 +8,7 @@ import android.content.pm.PackageManager import android.provider.CalendarContract import androidx.core.content.ContextCompat import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -32,6 +33,7 @@ class EventReminderReceiver : BroadcastReceiver() { @Inject lateinit var alertStore: ReminderAlertStore @Inject lateinit var notifier: ReminderNotifier @Inject lateinit var settingsPrefs: SettingsPrefs + @Inject lateinit var calendarPrefs: CalendarPrefs override fun onReceive(context: Context, intent: Intent) { if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return @@ -46,7 +48,13 @@ class EventReminderReceiver : BroadcastReceiver() { if (settingsPrefs.remindersEnabled.first()) { val now = System.currentTimeMillis() val due = alertStore.dueAlerts(now) - due.forEach { notifier.post(it) } + // Suppress reminders for calendars the user disabled in-app + // (mirrors the event filtering in CalendarRepositoryImpl), but + // still mark every due alert fired so the provider stops + // re-broadcasting the suppressed ones. + val disabled = calendarPrefs.disabledCalendarIds.first() + due.filterNot { it.calendarId in disabled } + .forEach { notifier.post(it) } alertStore.markFired(due.map { it.alertId }, now) } } finally { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt index b0a57fc..5a25f8c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt @@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() { private const val EXTRA_ALERT_ID = "alert_id" private const val EXTRA_EVENT_ID = "event_id" + private const val EXTRA_CALENDAR_ID = "calendar_id" private const val EXTRA_BEGIN = "begin" private const val EXTRA_END = "end" private const val EXTRA_TITLE = "title" @@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() { this.action = action putExtra(EXTRA_ALERT_ID, alert.alertId) putExtra(EXTRA_EVENT_ID, alert.eventId) + putExtra(EXTRA_CALENDAR_ID, alert.calendarId) putExtra(EXTRA_BEGIN, alert.beginMillis) putExtra(EXTRA_END, alert.endMillis) putExtra(EXTRA_TITLE, alert.title) @@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() { return ReminderAlert( alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L), eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L), + calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L), beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L), endMillis = intent.getLongExtra(EXTRA_END, 0L), title = intent.getStringExtra(EXTRA_TITLE).orEmpty(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt index 5d14579..54f68dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt @@ -16,6 +16,7 @@ import javax.inject.Singleton data class ReminderAlert( val alertId: Long, val eventId: Long, + val calendarId: Long, val beginMillis: Long, val endMillis: Long, /** Raw event title; may be blank — the notifier substitutes "(no title)". */ @@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor( ReminderAlert( alertId = c.getLong(0), eventId = c.getLong(1), - beginMillis = c.getLong(2), - endMillis = c.getLong(3), - title = c.getString(4).orEmpty(), - location = c.getString(5)?.takeIf { it.isNotBlank() }, - isAllDay = c.getInt(6) == 1, + calendarId = c.getLong(2), + beginMillis = c.getLong(3), + endMillis = c.getLong(4), + title = c.getString(5).orEmpty(), + location = c.getString(6)?.takeIf { it.isNotBlank() }, + isAllDay = c.getInt(7) == 1, ), ) } @@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor( val PROJECTION = arrayOf( CalendarContract.CalendarAlerts._ID, CalendarContract.CalendarAlerts.EVENT_ID, + CalendarContract.CalendarAlerts.CALENDAR_ID, CalendarContract.CalendarAlerts.BEGIN, CalendarContract.CalendarAlerts.END, CalendarContract.CalendarAlerts.TITLE, From 225f4c34912150c673d03572c5fb8a9a9e2be6ff Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 00:46:20 +0200 Subject: [PATCH 6/6] test(reminders): cover disabled-calendar alert filtering Extract the disabled-calendar filtering from EventReminderReceiver into a pure postableAlerts() function and unit-test it, since the receiver flow itself is not reachable from a plain JUnit test. Co-Authored-By: Claude Opus 4.8 --- .../data/reminders/EventReminderReceiver.kt | 21 +++++-- .../data/reminders/PostableAlertsTest.kt | 55 +++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/PostableAlertsTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt index 62078c9..dc6871f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt @@ -17,6 +17,17 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject +/** + * The due alerts that should actually surface as notifications: everything + * except alerts whose calendar the user has disabled in-app (mirroring the + * event filtering in CalendarRepositoryImpl). The caller still marks the full + * due set fired, so suppressed alerts are not re-broadcast by the provider. + */ +internal fun postableAlerts( + due: List, + disabledCalendarIds: Set, +): List = due.filterNot { it.calendarId in disabledCalendarIds } + /** * Becomes the app that turns the calendar provider's reminder alarms into * visible notifications (the Etar model — the provider broadcasts @@ -48,13 +59,11 @@ class EventReminderReceiver : BroadcastReceiver() { if (settingsPrefs.remindersEnabled.first()) { val now = System.currentTimeMillis() val due = alertStore.dueAlerts(now) - // Suppress reminders for calendars the user disabled in-app - // (mirrors the event filtering in CalendarRepositoryImpl), but - // still mark every due alert fired so the provider stops - // re-broadcasting the suppressed ones. val disabled = calendarPrefs.disabledCalendarIds.first() - due.filterNot { it.calendarId in disabled } - .forEach { notifier.post(it) } + // Suppress reminders for disabled calendars, but still mark + // every due alert fired so the provider stops re-broadcasting + // the suppressed ones. + postableAlerts(due, disabled).forEach { notifier.post(it) } alertStore.markFired(due.map { it.alertId }, now) } } finally { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/PostableAlertsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/PostableAlertsTest.kt new file mode 100644 index 0000000..222281e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/PostableAlertsTest.kt @@ -0,0 +1,55 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class PostableAlertsTest { + + private fun alert(alertId: Long, calendarId: Long) = ReminderAlert( + alertId = alertId, + eventId = alertId * 10, + calendarId = calendarId, + beginMillis = 0L, + endMillis = 0L, + title = "Event $alertId", + location = null, + isAllDay = false, + ) + + @Test + fun `keeps alerts when no calendar is disabled`() { + val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200)) + + val postable = postableAlerts(due, disabledCalendarIds = emptySet()) + + assertThat(postable).isEqualTo(due) + } + + @Test + fun `drops alerts for a disabled calendar`() { + val keep = alert(1, calendarId = 100) + val drop = alert(2, calendarId = 200) + + val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200)) + + assertThat(postable).containsExactly(keep) + } + + @Test + fun `drops every alert when all their calendars are disabled`() { + val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100)) + + val postable = postableAlerts(due, disabledCalendarIds = setOf(100)) + + assertThat(postable).isEmpty() + } + + @Test + fun `keeps multiple alerts from the same enabled calendar`() { + val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100)) + + val postable = postableAlerts(due, disabledCalendarIds = setOf(999)) + + assertThat(postable).isEqualTo(due) + } +}