From 2ae4c818ba7d84296745ea44270bce0cae3e53d4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 30 Jun 2026 15:19:25 +0200 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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) + } +} From 14b19cd902651773b28781c71a398ef11a34f13e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 12:02:14 +0200 Subject: [PATCH 07/12] fix(widget): keep Glance ActionCallback classes under R8 (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The month widget's prev/next/today controls and the agenda widget's refresh run through Glance `actionRunCallback()`, which persists the callback's class name into the click PendingIntent and instantiates it reflectively when tapped. Under R8 full mode (AGP 9 default) those callback classes — only ever referenced reflectively — were renamed and lost their no-arg constructor, so the lookup failed silently and the arrows did nothing on release builds (while actionStartActivity taps, which need no reflection, kept working). Keep every ActionCallback's name and constructor. Verified against the release mapping: ShiftMonthAction/ResetMonthAction/RefreshAgendaAction now retain their original fully-qualified names. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/proguard-rules.pro | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index fd9c2c8..8b1a5d3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -16,6 +16,18 @@ -keep class * extends androidx.room.RoomDatabase { *; } -dontwarn androidx.room.paging.** +# Glance runs an @Composable's `actionRunCallback()` by persisting the +# callback's fully-qualified class name into the click PendingIntent, then +# reflectively instantiating it (Class.forName(name).newInstance()) when the tap +# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only +# ever referenced reflectively — get renamed or have their no-arg constructor +# stripped, so the lookup fails silently and the tap does nothing. In the month +# and agenda widgets that broke every run-callback control (the prev/next/today +# month arrows and the agenda refresh) in release builds while actionStartActivity +# taps, which ride a PendingIntent and need no reflection, kept working. Keep +# every ActionCallback's name and constructor intact. +-keep class * implements androidx.glance.appwidget.action.ActionCallback { (...); } + # WorkManager instantiates an InputMerger reflectively (Class.newInstance) from # the fully-qualified class name persisted in the WorkSpec, so the class must # keep both its name and a no-arg constructor. Glance renders every widget From 40d70d9f2b8c149149e816b97bb2e9010cd4f445 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 12:02:14 +0200 Subject: [PATCH 08/12] feat(widget): open the app from the widget headers (#18, #20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a widget header now opens the app on a sensible top-level view, via a shared WidgetNavRequest.OpenView(view?) — a concrete view roots there over the default home, a null view resolves to the default home: - Month widget: the month/year title opens the month view (#18). Its "today" button now snaps the grid back to the current month in place (the reset that used to sit on the title), so paging + jump-to-today are both reachable and the title is free to open the app. - Agenda widget: the "Upcoming" title opens the default view (#20), so users whose home view is Week/Month can reach it in one tap instead of drilling through a day or event. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 ++++++++++ .../jeanlucmakiola/calendula/MainActivity.kt | 24 +++++++++++++++++++ .../calendula/ui/CalendarHost.kt | 11 +++++++++ .../calendula/ui/WidgetNavRequest.kt | 9 +++++++ .../calendula/widget/agenda/AgendaWidget.kt | 8 ++++++- .../calendula/widget/month/MonthWidget.kt | 15 +++++++----- 6 files changed, 73 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d973ab..5e20ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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]). +- Widget headers now open the app. Tapping the month/year title on the month + widget opens the app on the month view, and tapping the "Upcoming" title on + the agenda widget opens it on your default view — so there's a one-tap way + back into the app that lands where you'd expect, instead of only through a day + or event. The month widget's "today" button now snaps the grid back to the + current month in place. Thanks to @rgz46vic and @ptab for the suggestions + ([#18], [#20]). ### Fixed +- Month widget arrows and "today" button work again. On release builds the + prev/next-month arrows and the jump-to-today control on the month widget did + nothing when tapped — code shrinking had stripped the tap handlers behind + them. They respond again. Thanks to @rgz46vic for the report ([#18]). - 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 @@ -754,3 +765,5 @@ automatically, with zero telemetry and no internet permission. [#14]: https://codeberg.org/jlmakiola/calendula/issues/14 [#16]: https://codeberg.org/jlmakiola/calendula/issues/16 [#17]: https://codeberg.org/jlmakiola/calendula/issues/17 +[#18]: https://codeberg.org/jlmakiola/calendula/issues/18 +[#20]: https://codeberg.org/jlmakiola/calendula/issues/20 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index ede492a..b74872a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -193,6 +193,12 @@ class MainActivity : AppCompatActivity() { // An external date tap (launcher/clock) has no widget source, so it opens // the day view rooted over the default home view (OpenDate source = null). calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) } + // A widget header tap: open a top-level view with no date drill-in. The + // empty string carried by [openViewIntent] means "the default home view". + if (hasExtra(EXTRA_OPEN_VIEW)) { + val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty() + return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name }) + } val source = sourceViewOrNull() val eventId = getLongExtra(EXTRA_EVENT_ID, -1L) return when { @@ -244,6 +250,10 @@ class MainActivity : AppCompatActivity() { private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO" private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE" + // A widget header tap asking to open a top-level view (no date drill-in). + // Its value is the target [CalendarView] name, or "" for the default view. + private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW" + // The [CalendarView] (by name) of the widget a launch came from. Roots the // in-app back stack in that view; absent for non-widget launches (reminders). private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW" @@ -307,5 +317,19 @@ class MainActivity : AppCompatActivity() { putExtra(EXTRA_DATE_ISO, date.toString()) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + + /** + * Open the app on a top-level [view] with no date drill-in — a widget + * header tap. A null [view] opens the user's default home view (the agenda + * widget's "Upcoming" title); a concrete view roots there over the default + * home (the month widget's month/year title → [CalendarView.Month]). The + * per-view data URI keeps distinct headers' PendingIntents from collapsing. + */ + fun openViewIntent(context: Context, view: CalendarView?): Intent = + Intent(context, MainActivity::class.java).apply { + data = "calendula://view/${view?.name ?: "default"}".toUri() + putExtra(EXTRA_OPEN_VIEW, view?.name ?: "") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 50ee186..dc2cc4f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -212,6 +212,17 @@ fun CalendarHost( detailKey = key onWidgetNavConsumed() } + is WidgetNavRequest.OpenView -> { + // A widget header tap: land on a top-level view with no date + // drill-in. Reveal it by dropping any covering overlay, then root + // the stack on the target (null → the default home) over the + // default home — so backing out returns to the default, then exits. + dismissCoveringOverlays() + createDateIso = null + pendingDayIso = null + viewStack = viewBaseStack(defaultView, req.view ?: defaultView) + onWidgetNavConsumed() + } is WidgetNavRequest.Create -> { // External "new event" entries (QS tile / launcher shortcut / // widget) must land on top of whatever is open — the form overlay diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt index 6588d10..985806b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/WidgetNavRequest.kt @@ -31,4 +31,13 @@ sealed interface WidgetNavRequest { /** Open the create-event form prefilled for [dateIso] (today when null). */ data class Create(val dateIso: String?) : WidgetNavRequest + + /** + * Open the app rooted on a top-level [view] with no date drill-in — a widget + * header tap. A null [view] means "the user's default home view" (the agenda + * widget's "Upcoming" title, issue #20); a concrete view roots there over the + * default home (the month widget's month/year title → [CalendarView.Month], + * issue #18), so backing out returns to the default view, then exits. + */ + data class OpenView(val view: CalendarView?) : WidgetNavRequest } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 19c06c5..0174eed 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -193,6 +193,8 @@ private fun AgendaHeader() { modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { + // Tap the "Upcoming" title to open the app on the user's default view + // (issue #20) — a null target resolves to the default home in the host. Text( text = context.getString(R.string.widget_agenda_title), style = TextStyle( @@ -200,7 +202,11 @@ private fun AgendaHeader() { fontSize = 16.sp, fontWeight = FontWeight.Medium, ), - modifier = GlanceModifier.defaultWeight(), + modifier = GlanceModifier + .defaultWeight() + .clickable( + actionStartActivity(MainActivity.openViewIntent(context, view = null)), + ), ) IconButton( resId = R.drawable.ic_widget_refresh, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 1799bd5..d08e65b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -194,6 +194,8 @@ private fun MonthHeader(label: String) { ), ), ) + // Tap the month/year title to open the app on the month view (issue #18), + // rooted over the default home so backing out returns there. Text( text = label, style = TextStyle( @@ -204,16 +206,17 @@ private fun MonthHeader(label: String) { ), modifier = GlanceModifier .defaultWeight() - .clickable(actionRunCallback()), + .clickable( + actionStartActivity( + MainActivity.openViewIntent(context, CalendarView.Month), + ), + ), ) + // The "today" button snaps the grid back to the current month in place. HeaderIcon( resId = R.drawable.ic_widget_today, contentDescription = context.getString(R.string.widget_today), - onClick = GlanceModifier.clickable( - actionStartActivity( - MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month), - ), - ), + onClick = GlanceModifier.clickable(actionRunCallback()), ) HeaderIcon( resId = R.drawable.ic_widget_chevron_right, From b9e800e9fc452a6826eddbbba0cc8e952785d736 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 12:19:50 +0200 Subject: [PATCH 09/12] feat(views): customizable quick-switch cycle + drawer order (#24) Add a "Views" settings section that lets users pick which views the top-bar quick-switch button cycles through and drag to reorder them, plus an independent drag-to-reorder for the navigation drawer's view list. Two separate configs: a view turned off in the quick-switch cycle is still reachable from the drawer, which always lists every view. - QuickSwitchConfig (order + enabled set) and a drawer order persisted in SettingsPrefs (comma-joined enum names; "!" marks a disabled view). Missing views append enabled and unknown names drop, so a future view defaults into both lists. - The pill cycles through the configured enabled views in order; the drawer renders CalendarDrawer from the drawer order. Both threaded from CalendarHost via CalendarHostViewModel. - New ReorderableColumn: dependency-free, measurement-driven drag reordering for the short grouped-card settings lists (no LazyColumn, since settings are a single verticalScroll column). Commits one write per gesture. - The switch needs two targets, so the last two enabled views can't be turned off. Co-Authored-By: Claude Opus 4.8 --- .../calendula/data/prefs/SettingsPrefs.kt | 66 ++++++++ .../calendula/ui/CalendarHost.kt | 13 ++ .../calendula/ui/CalendarHostViewModel.kt | 19 +++ .../calendula/ui/agenda/AgendaScreen.kt | 6 +- .../calendula/ui/common/CalendarDrawer.kt | 5 +- .../calendula/ui/common/CalendarView.kt | 30 +++- .../calendula/ui/common/ReorderableColumn.kt | 103 +++++++++++++ .../calendula/ui/day/DayScreen.kt | 6 +- .../calendula/ui/month/MonthScreen.kt | 6 +- .../calendula/ui/settings/SettingsScreen.kt | 145 +++++++++++++++++- .../calendula/ui/settings/SettingsUiState.kt | 6 + .../ui/settings/SettingsViewModel.kt | 27 +++- .../calendula/ui/week/WeekScreen.kt | 6 +- app/src/main/res/values/strings.xml | 7 + .../calendula/data/prefs/SettingsPrefsTest.kt | 75 +++++++++ .../calendula/ui/common/ViewBackStackTest.kt | 29 ++++ 16 files changed, 539 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt 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..a6a6ee6 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 @@ -12,6 +12,8 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -202,6 +204,34 @@ class SettingsPrefs @Inject constructor( store.edit { it[DEFAULT_VIEW_KEY] = view.name } } + /** + * Quick-switch button customisation (#24): the ordered full view list plus + * which views are enabled in the cycle. Stored comma-joined by enum name, a + * "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing + * views are appended enabled and unknown names dropped, so a future view + * defaults into the cycle. An absent key means [QuickSwitchConfig.Default]. + */ + val quickSwitchConfig: Flow = store.data.map { prefs -> + parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY]) + } + + suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) { + store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) } + } + + /** + * Navigation-drawer view order (#24). Comma-joined enum names; missing views + * are appended in default order and unknown names dropped. Absent key means + * [IMPLEMENTED_VIEWS] (the historical fixed order). + */ + val drawerViewOrder: Flow> = store.data.map { prefs -> + parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY]) + } + + suspend fun setDrawerViewOrder(order: List) { + store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } } + } + /** * Optional event-form fields shown by default (the rest hide behind * "more fields"). Stored comma-joined by enum name: an absent key means @@ -433,6 +463,40 @@ class SettingsPrefs @Inject constructor( .toSet() } + /** Parse a plain comma-joined view order, completed to every implemented view. */ + private fun parseViewOrder(stored: String?): List = + completeViewOrder( + stored?.split(',').orEmpty() + .mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } }, + ) + + /** Parse the quick-switch config; "!"-prefixed names are disabled. */ + private fun parseQuickSwitch(stored: String?): QuickSwitchConfig { + if (stored == null) return QuickSwitchConfig.Default + val parsed = stored.split(',').mapNotNull { raw -> + val token = raw.trim() + val disabled = token.startsWith("!") + val name = if (disabled) token.drop(1) else token + CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled } + } + val order = completeViewOrder(parsed.map { it.first }) + // Only views explicitly stored disabled are excluded; anything appended + // (a view added in a later release) defaults into the cycle. + val disabled = parsed.filter { it.second }.map { it.first }.toSet() + return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet()) + } + + private fun serializeQuickSwitch(config: QuickSwitchConfig): String = + completeViewOrder(config.order).joinToString(",") { view -> + if (view in config.enabled) view.name else "!${view.name}" + } + + /** Keep the given order (de-duplicated), then append any views it omits. */ + private fun completeViewOrder(seen: List): List { + val ordered = seen.distinct() + return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered } + } + companion object { internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") @@ -445,6 +509,8 @@ class SettingsPrefs @Inject constructor( internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") + internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") + internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 50ee186..6cddce2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -79,6 +79,11 @@ fun CalendarHost( // correcting it. Brief blank first frame, matching the onboarding gate above. val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return + // View customisation (#24): the quick-switch cycle and drawer order. Both have + // sensible non-empty initial values, so they're ready before the first frame. + val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value + val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value + var viewStack by rememberSaveable(stateSaver = viewStackSaver) { mutableStateOf(listOf(defaultView)) } @@ -260,6 +265,8 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) CalendarView.Day -> DayScreen( selectedView = currentView, @@ -269,6 +276,8 @@ fun CalendarHost( onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, initialDateIso = pendingDayIso, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) CalendarView.Month -> MonthScreen( selectedView = currentView, @@ -277,6 +286,8 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) CalendarView.Agenda -> AgendaScreen( selectedView = currentView, @@ -285,6 +296,8 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt index 9a9c2b7..773f76d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt @@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(5_000L), initialValue = null, ) + + /** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */ + val quickSwitchViews: StateFlow> = prefs.quickSwitchConfig + .map { it.cycle } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = IMPLEMENTED_VIEWS, + ) + + /** Order of the views in the navigation drawer (#24); every view always shown. */ + val drawerViewOrder: StateFlow> = prefs.drawerViewOrder + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = IMPLEMENTED_VIEWS, + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 4db125c..8286ee8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -64,6 +64,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.Position @@ -96,6 +97,8 @@ fun AgendaScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, viewModel: AgendaViewModel = hiltViewModel(), ) { @@ -120,6 +123,7 @@ fun AgendaScreen( CalendarDrawer( currentView = selectedView, currentDate = anchor, + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -140,7 +144,7 @@ fun AgendaScreen( topBar = { AgendaTopBar( selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt index 2b66b98..37b2c29 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt @@ -59,6 +59,7 @@ fun CalendarDrawer( onSelectView: (CalendarView) -> Unit, onJumpToDate: (LocalDate) -> Unit, onSettings: () -> Unit, + viewOrder: List = IMPLEMENTED_VIEWS, ) { var showDatePicker by remember { mutableStateOf(false) } @@ -73,10 +74,10 @@ fun CalendarDrawer( DrawerHeader() DrawerSectionHeader(stringResource(R.string.view_section)) - IMPLEMENTED_VIEWS.forEachIndexed { index, view -> + viewOrder.forEachIndexed { index, view -> GroupedRow( title = stringResource(view.labelRes), - position = positionOf(index, IMPLEMENTED_VIEWS.size), + position = positionOf(index, viewOrder.size), selected = view == currentView, minHeight = 56.dp, leading = { Icon(view.icon, contentDescription = null) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt index 57151ab..b701d95 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt @@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List = /** Next view in [available], wrapping around. Falls back to Month if absent. */ fun CalendarView.next(available: List = IMPLEMENTED_VIEWS): CalendarView { + if (available.isEmpty()) return this val i = available.indexOf(this) - if (i < 0) return available.firstOrNull() ?: CalendarView.Month + if (i < 0) return available.first() return available[(i + 1) % available.size] } +/** + * The user's customisation of the top-bar quick-switch button (#24): which views + * it cycles through ([enabled]) and in what [order]. [order] always lists every + * implemented view — the settings screen reorders the whole set — while [cycle] + * is the subset the pill actually steps through, in [order]. The navigation + * drawer keeps its own separate order and always lists every view, so a view + * disabled here stays reachable there. + */ +data class QuickSwitchConfig( + val order: List, + val enabled: Set, +) { + /** Views the pill steps through, in [order]. */ + val cycle: List get() = order.filter { it in enabled } + + companion object { + /** All views, in default order, all enabled. */ + val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet()) + + /** + * Fewest views that keep the switch meaningful — a "switch" needs at + * least two targets, so the settings screen blocks disabling below this. + */ + const val MIN_ENABLED = 2 + } +} + /** * The top-level view back stack (bottom → top): the [default] home view always * sits at the bottom. Pressing back pops one level until only the home view diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt new file mode 100644 index 0000000..369ee3a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt @@ -0,0 +1,103 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.zIndex + +/** + * A vertical list whose rows can be dragged into a new order by their handle. + * + * Built for the short, fixed grouped-card lists in Settings (#24) — no external + * dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings + * screens are a single [androidx.compose.foundation.verticalScroll] column, which + * can't nest a scrolling list). [rowContent] receives the [Position] for the + * row's current place in the order (so it can reuse [GroupedRow]'s card shaping) + * and a `dragHandle` [Modifier] to attach to whatever element should start a + * drag. While a row is held it floats above the others, which reflow live; the + * final order is committed once via [onReorder] on release, so the persistence + * layer sees one write per gesture, not one per crossed row. + * + * Reordering is measurement-driven: each row reports its own height, so rows of + * unequal height still swap at the right point. + */ +@Composable +fun ReorderableColumn( + items: List, + keyOf: (T) -> Any, + onReorder: (List) -> Unit, + modifier: Modifier = Modifier, + rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit, +) { + // Local working copy so rows reflow instantly during a drag; re-seeded when + // the incoming list changes (including the echo of our own committed order). + var order by remember(items) { mutableStateOf(items) } + var draggedKey by remember { mutableStateOf(null) } + // Translation of the held row from its settled slot; a swap folds one slot + // height back into this so the row stays under the finger across the swap. + var dragOffsetY by remember { mutableStateOf(0f) } + val heights = remember { mutableStateMapOf() } + + Column(modifier) { + order.forEachIndexed { index, item -> + val key = keyOf(item) + val isDragging = key == draggedKey + val dragHandle = Modifier.pointerInput(key) { + detectDragGestures( + onDragStart = { + draggedKey = key + dragOffsetY = 0f + }, + onDragEnd = { + draggedKey = null + dragOffsetY = 0f + onReorder(order) + }, + onDragCancel = { + draggedKey = null + dragOffsetY = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + dragOffsetY += dragAmount.y + val cur = order.indexOfFirst { keyOf(it) == key } + if (cur < 0) return@detectDragGestures + // Swap once the row has travelled past half of the + // neighbour it's heading toward, in the drag direction. + if (dragAmount.y > 0 && cur < order.lastIndex) { + val nextHeight = heights[keyOf(order[cur + 1])] ?: 0 + if (nextHeight > 0 && dragOffsetY > nextHeight / 2f) { + order = order.toMutableList().apply { add(cur + 1, removeAt(cur)) } + dragOffsetY -= nextHeight + } + } else if (dragAmount.y < 0 && cur > 0) { + val prevHeight = heights[keyOf(order[cur - 1])] ?: 0 + if (prevHeight > 0 && dragOffsetY < -prevHeight / 2f) { + order = order.toMutableList().apply { add(cur - 1, removeAt(cur)) } + dragOffsetY += prevHeight + } + } + }, + ) + } + Box( + Modifier + .onSizeChanged { heights[key] = it.height } + .zIndex(if (isDragging) 1f else 0f) + .graphicsLayer { translationY = if (isDragging) dragOffsetY else 0f }, + ) { + rowContent(item, positionOf(index, order.size), dragHandle, isDragging) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 97cbdce..ce43538 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -72,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition @@ -116,6 +117,8 @@ fun DayScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, initialDateIso: String? = null, viewModel: DayViewModel = hiltViewModel(), @@ -175,6 +178,7 @@ fun DayScreen( CalendarDrawer( currentView = selectedView, currentDate = date, + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -196,7 +200,7 @@ fun DayScreen( DayTopBar( date = date, selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index f57f6e3..3413ce7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -68,6 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute @@ -98,6 +99,8 @@ fun MonthScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), ) { @@ -157,6 +160,7 @@ fun MonthScreen( CalendarDrawer( currentView = selectedView, currentDate = LocalDate(month.year, month.month, 1), + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -178,7 +182,7 @@ fun MonthScreen( MonthTopBar( month = month, selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, 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..3c3c97a 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 @@ -41,6 +41,8 @@ import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Favorite @@ -99,9 +101,13 @@ import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel +import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn +import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.OptionPicker @@ -121,7 +127,7 @@ import java.time.format.TextStyle as JavaTextStyle import java.util.Calendar /** The settings sub-screens reached from the hub's category rows. */ -private enum class SettingsSection { Appearance, EventForm, Notifications } +private enum class SettingsSection { Appearance, Views, EventForm, Notifications } /** * Token-based accent for a leading icon chip (container / on-container pair). @@ -166,6 +172,13 @@ fun SettingsScreen( ) { AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) } + AnimatedVisibility( + visible = section == SettingsSection.Views, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }) + } AnimatedVisibility( visible = section == SettingsSection.EventForm, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), @@ -204,6 +217,13 @@ private fun SettingsHub( leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) }, onClick = { onOpenSection(SettingsSection.Appearance) }, ) + GroupedRow( + title = stringResource(R.string.settings_section_views), + summary = stringResource(R.string.settings_views_subtitle), + position = Position.Middle, + leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) }, + onClick = { onOpenSection(SettingsSection.Views) }, + ) GroupedRow( title = stringResource(R.string.settings_section_event_form), summary = stringResource(R.string.settings_event_form_subtitle), @@ -664,6 +684,129 @@ private fun AppearanceScreen( } } +/** + * Views (#24): reorder the top-bar quick-switch cycle and choose which views it + * steps through, plus reorder the navigation-drawer list. Two independent lists — + * a view disabled in the quick-switch cycle is still reachable from the drawer, + * which always lists every view. The switch needs at least two targets, so the + * last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off. + */ +@Composable +private fun ViewsScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + CollapsingScaffold( + title = stringResource(R.string.settings_section_views), + onBack = onBack, + ) { + val config = state.quickSwitchConfig + + SectionHeader(stringResource(R.string.settings_quick_switch_header)) + SettingsHint(stringResource(R.string.settings_quick_switch_hint)) + Spacer(Modifier.height(8.dp)) + // Turning a view off is blocked once only the minimum remain enabled. + val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED + ReorderableColumn( + items = config.order, + keyOf = { it }, + onReorder = { viewModel.setQuickSwitchConfig(config.copy(order = it)) }, + ) { view, position, dragHandle, isDragging -> + val checked = view in config.enabled + ViewRow( + view = view, + position = position, + isDragging = isDragging, + dragHandle = dragHandle, + dimmed = !checked, + trailing = { + Switch( + checked = checked, + // Keep the last two on: with fewer, the pill can't switch. + enabled = !checked || canDisable, + onCheckedChange = { on -> + val enabled = if (on) config.enabled + view else config.enabled - view + viewModel.setQuickSwitchConfig(config.copy(enabled = enabled)) + }, + ) + }, + ) + } + + Spacer(Modifier.height(24.dp)) + SectionHeader(stringResource(R.string.settings_drawer_order_header)) + SettingsHint(stringResource(R.string.settings_drawer_order_hint)) + Spacer(Modifier.height(8.dp)) + ReorderableColumn( + items = state.drawerViewOrder, + keyOf = { it }, + onReorder = { viewModel.setDrawerViewOrder(it) }, + ) { view, position, dragHandle, isDragging -> + ViewRow( + view = view, + position = position, + isDragging = isDragging, + dragHandle = dragHandle, + ) + } + } +} + +/** One reorderable view row: the view's icon and name, an optional [trailing] + * control, and a drag handle carrying the [dragHandle] gesture modifier. */ +@Composable +private fun ViewRow( + view: CalendarView, + position: Position, + isDragging: Boolean, + dragHandle: Modifier, + dimmed: Boolean = false, + trailing: @Composable (() -> Unit)? = null, +) { + GroupedRow( + title = stringResource(view.labelRes), + position = position, + dimmed = dimmed, + minHeight = 64.dp, + container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null, + leading = { + Icon( + imageVector = view.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = { + Row(verticalAlignment = Alignment.CenterVertically) { + trailing?.invoke() + if (trailing != null) Spacer(Modifier.width(8.dp)) + Box( + modifier = dragHandle.size(48.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = stringResource(R.string.reorder_drag_handle), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + ) +} + +/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */ +@Composable +private fun SettingsHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) +} + @Composable private fun EventFormScreen( state: SettingsUiState, 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..43667f9 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 @@ -9,6 +9,8 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -37,6 +39,10 @@ data class SettingsUiState( val agendaShowRangeBar: Boolean = true, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, + /** Which views the top-bar quick-switch button cycles through, and their order (#24). */ + val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default, + /** Order of the views in the navigation drawer (#24); every view is always listed. */ + val drawerViewOrder: List = IMPLEMENTED_VIEWS, /** Optional event-form fields shown by default (rest behind "more fields"). */ val defaultFormFields: Set = SettingsPrefs.DEFAULT_FORM_FIELDS, /** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */ 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..cc1b263 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 @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget @@ -103,8 +104,14 @@ class SettingsViewModel @Inject constructor( prefs.autofocusEventTitle, prefs.pastEventDisplay, prefs.dimCompletedEvents, - ::MiscSettings, - ), + // View customisation (#24) folded into one flow so it fits this + // group — the outer combine is already at its five-arg limit. + combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer -> + ViewCustomization(quickSwitch, drawer) + }, + ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> + MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) + }, ) { base, defaults, overrides, views, misc -> base.copy( defaultView = views.defaultView, @@ -116,6 +123,8 @@ class SettingsViewModel @Inject constructor( autofocusEventTitle = misc.autofocusEventTitle, pastEventDisplay = misc.pastEventDisplay, dimCompletedEvents = misc.dimCompletedEvents, + quickSwitchConfig = misc.viewCustomization.quickSwitch, + drawerViewOrder = misc.viewCustomization.drawerOrder, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -164,6 +173,12 @@ class SettingsViewModel @Inject constructor( val autofocusEventTitle: Boolean, val pastEventDisplay: PastEventDisplay, val dimCompletedEvents: Boolean, + val viewCustomization: ViewCustomization, + ) + + private data class ViewCustomization( + val quickSwitch: QuickSwitchConfig, + val drawerOrder: List, ) fun setThemeMode(mode: ThemeMode) { @@ -247,6 +262,14 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) } } + fun setQuickSwitchConfig(config: QuickSwitchConfig) { + viewModelScope.launch { prefs.setQuickSwitchConfig(config) } + } + + fun setDrawerViewOrder(order: List) { + viewModelScope.launch { prefs.setDrawerViewOrder(order) } + } + fun setRemindersEnabled(enabled: Boolean) { viewModelScope.launch { prefs.setRemindersEnabled(enabled) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index e91de43..c40ab29 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -81,6 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.NowLine @@ -131,6 +132,8 @@ fun WeekScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, viewModel: WeekViewModel = hiltViewModel(), ) { @@ -195,6 +198,7 @@ fun WeekScreen( CalendarDrawer( currentView = selectedView, currentDate = weekStart, + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -216,7 +220,7 @@ fun WeekScreen( WeekTopBar( weekStart = weekStart, selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d121f90..334e980 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -308,6 +308,12 @@ %d day %d days + Views + Quick-switch button + Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu. + Navigation menu + Drag to reorder the views listed in the navigation menu. + Drag to reorder New event form Fields shown by default — everything else sits behind \"More fields\" Focus title on new event @@ -343,6 +349,7 @@ Add or improve a language on Weblate Theme, default view, week start + Quick-switch button and menu order Default fields for new events Event reminders About 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..ef2f097 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 @@ -6,6 +6,9 @@ import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -149,6 +152,78 @@ class SettingsPrefsTest { assertThat(prefs.defaultFormFields.first()).isEmpty() } + @Test + fun `quick-switch defaults to every view enabled in default order`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + val config = prefs.quickSwitchConfig.first() + assertThat(config.order).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder() + assertThat(config.enabled).containsExactlyElementsIn(IMPLEMENTED_VIEWS) + assertThat(config.cycle).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder() + } + + @Test + fun `quick-switch config round-trips order and disabled views`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + val config = QuickSwitchConfig( + order = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day), + enabled = setOf(CalendarView.Agenda, CalendarView.Month), + ) + prefs.setQuickSwitchConfig(config) + val loaded = prefs.quickSwitchConfig.first() + assertThat(loaded.order).containsExactly( + CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day, + ).inOrder() + assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month) + assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder() + } + + @Test + fun `quick-switch parse appends missing views enabled and drops unknowns`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + // Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent. + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.QUICK_SWITCH_VIEWS_KEY] = "!Day,Week,Hologram" + m + } + val config = prefs.quickSwitchConfig.first() + // Stored order first (Day, Week), then the omitted views in default order. + assertThat(config.order).containsExactly( + CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda, + ).inOrder() + // Day was explicitly disabled; the appended Month & Agenda default enabled. + assertThat(config.enabled).containsExactly( + CalendarView.Week, CalendarView.Month, CalendarView.Agenda, + ) + } + + @Test + fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.drawerViewOrder.first()).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder() + prefs.setDrawerViewOrder( + listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month), + ) + assertThat(prefs.drawerViewOrder.first()).containsExactly( + CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month, + ).inOrder() + } + + @Test + fun `drawer order parse appends missing views and drops unknowns`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.DRAWER_VIEW_ORDER_KEY] = "Agenda,Nope,Week" + m + } + assertThat(prefs.drawerViewOrder.first()).containsExactly( + CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day, + ).inOrder() + } + @Test fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt index 7c4f118..93d5028 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt @@ -91,4 +91,33 @@ class ViewBackStackTest { assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda)) .containsExactly(CalendarView.Agenda) } + + @Test + fun `next cycles through the configured views in order, wrapping around`() { + val cycle = listOf(CalendarView.Month, CalendarView.Agenda) + assertThat(CalendarView.Month.next(cycle)).isEqualTo(CalendarView.Agenda) + // Wraps back to the first from the last. + assertThat(CalendarView.Agenda.next(cycle)).isEqualTo(CalendarView.Month) + } + + @Test + fun `next from a view outside the cycle lands on the first enabled view`() { + // Day was disabled in the quick-switch cycle but is still the current view. + val cycle = listOf(CalendarView.Week, CalendarView.Month) + assertThat(CalendarView.Day.next(cycle)).isEqualTo(CalendarView.Week) + } + + @Test + fun `next on an empty cycle stays put`() { + assertThat(CalendarView.Week.next(emptyList())).isEqualTo(CalendarView.Week) + } + + @Test + fun `quick-switch cycle keeps enabled views in their configured order`() { + val config = QuickSwitchConfig( + order = listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Month, CalendarView.Week), + enabled = setOf(CalendarView.Agenda, CalendarView.Month), + ) + assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder() + } } From 580dc5a669bf0cb44dae4429bd23b1102805a874 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 12:23:33 +0200 Subject: [PATCH 10/12] feat(widget): tap anywhere on a month-widget day to open it (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only the small day-number was a tap target, so tapping the blank area of a day cell (or its "+N" overflow) did nothing. Make every non-event part of a day column open that day — the day number, the empty lane cells, and the overflow row — via a shared openDayAction, so a tap anywhere on a day opens it, matching the in-app month grid. Event bars keep opting out to open their own detail. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/widget/month/MonthWidget.kt | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index d08e65b..89b401b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -304,6 +304,15 @@ private fun WeekRow( } } +/** + * Open [date]'s day view rooted in the month view (so back returns to the month + * grid) — the same target the in-app month grid uses when a day cell is tapped. + * Shared by every tappable part of a day column so, as in the app, a tap anywhere + * on a day opens it; only an event bar on top opts out to open its own detail. + */ +private fun openDayAction(context: Context, date: LocalDate) = + actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) + @Composable private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { val context = LocalContext.current @@ -311,11 +320,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: modifier = GlanceModifier .width(colW) .height(DAY_NUMBER_HEIGHT) - // Tap a day number to open that day, rooted in the month view so back - // returns to the month grid. - .clickable( - actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)), - ), + .clickable(openDayAction(context, date)), contentAlignment = Alignment.Center, ) { Box( @@ -342,6 +347,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: @Composable private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { + val context = LocalContext.current Row(modifier = GlanceModifier.fillMaxWidth()) { var col = 0 while (col < 7) { @@ -355,7 +361,14 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { if (timed != null) { SpanBar(event = timed, dark = dark, width = colW) } else { - Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {} + // Empty lane cell: a tap opens that day, so blank space in a + // day column is a day-open target just like the number is. + Box( + GlanceModifier + .width(colW) + .height(LANE_HEIGHT) + .clickable(openDayAction(context, week.days[col])), + ) {} } col += 1 } @@ -404,14 +417,20 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) { @Composable private fun OverflowRow(week: MonthWeek, colW: Dp) { + val context = LocalContext.current Row(modifier = GlanceModifier.fillMaxWidth()) { week.days.forEachIndexed { col, date -> val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size) val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown + // The overflow row is part of the day column too: tapping it (whether + // it shows "+N" or is blank) opens that day, same as the app. Box( - modifier = GlanceModifier.width(colW).height(LANE_HEIGHT), + modifier = GlanceModifier + .width(colW) + .height(LANE_HEIGHT) + .clickable(openDayAction(context, date)), contentAlignment = Alignment.CenterStart, ) { if (hidden > 0) { From e5aa90bdd6f315fbd94eb5fb348ecbed083cf264 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 12:37:42 +0200 Subject: [PATCH 11/12] docs(changelog): note month-widget day tap opens that day (#18) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e20ae8..ab6a6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 widget opens the app on the month view, and tapping the "Upcoming" title on the agenda widget opens it on your default view — so there's a one-tap way back into the app that lands where you'd expect, instead of only through a day - or event. The month widget's "today" button now snaps the grid back to the - current month in place. Thanks to @rgz46vic and @ptab for the suggestions - ([#18], [#20]). + or event. On the month widget, tapping anywhere on a day — not just the small + date number — now opens that day, and the "today" button snaps the grid back + to the current month in place. Thanks to @rgz46vic and @ptab for the + suggestions ([#18], [#20]). ### Fixed - Month widget arrows and "today" button work again. On release builds the From 37f0e22911dab4941696c53c0520f88d105f910c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 13:25:06 +0200 Subject: [PATCH 12/12] fix(views): correct snapping + animation for reorderable view lists Rework ReorderableColumn: the first cut measured each row, but grouped cards have position-dependent padding, so slot heights varied and rows snapped to the wrong places with no animation. Now rows are a fixed uniform pitch, the target slot is the whole-pitches dragged, neighbours slide aside with a spring, and the held row settles onto its slot before the order commits. Add a lift (scale + shadow) on the held row and cancel an in-flight settle if a new drag pre-empts it. GroupedRow gains gapBelow so the reorderable list can own uniform inter-row spacing. On-device verified on Pixel 10. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/common/GroupedList.kt | 10 +- .../calendula/ui/common/ReorderableColumn.kt | 151 ++++++++++++------ .../calendula/ui/settings/SettingsScreen.kt | 5 +- 3 files changed, 114 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt index c183aab..dd39503 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt @@ -142,6 +142,9 @@ fun GroupedRow( dimmed: Boolean = false, container: Color? = null, minHeight: Dp = 72.dp, + // The 2.dp separation between rows in a run. Suppressed by the reorderable + // list, which owns uniform spacing itself so every slot has the same pitch. + gapBelow: Boolean = true, leading: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null, @@ -160,9 +163,10 @@ fun GroupedRow( topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, ) } - val gap = when (position) { - Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) - Position.Bottom, Position.Alone -> Modifier + val gap = when { + !gapBelow -> Modifier + position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp) + else -> Modifier } val itemColors = if (selected) { ListItemDefaults.colors( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt index 369ee3a..d6a1f5e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt @@ -1,19 +1,38 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */ +val ReorderableRowHeight: Dp = 64.dp +private val RowGap: Dp = 2.dp /** * A vertical list whose rows can be dragged into a new order by their handle. @@ -21,15 +40,15 @@ import androidx.compose.ui.zIndex * Built for the short, fixed grouped-card lists in Settings (#24) — no external * dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings * screens are a single [androidx.compose.foundation.verticalScroll] column, which - * can't nest a scrolling list). [rowContent] receives the [Position] for the - * row's current place in the order (so it can reuse [GroupedRow]'s card shaping) - * and a `dragHandle` [Modifier] to attach to whatever element should start a - * drag. While a row is held it floats above the others, which reflow live; the - * final order is committed once via [onReorder] on release, so the persistence - * layer sees one write per gesture, not one per crossed row. + * can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a + * uniform gap, so a row's target slot is simply how many whole pitches it has + * been dragged. The held row follows the finger while the others slide out of + * its way (animated); on release the held row settles into its slot, then the + * order is committed with a single [onReorder] call. * - * Reordering is measurement-driven: each row reports its own height, so rows of - * unequal height still swap at the right point. + * [rowContent] receives the [Position] for the row's place in the order (to reuse + * [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing) + * and a `dragHandle` [Modifier] to attach to the element that starts a drag. */ @Composable fun ReorderableColumn( @@ -39,64 +58,100 @@ fun ReorderableColumn( modifier: Modifier = Modifier, rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit, ) { - // Local working copy so rows reflow instantly during a drag; re-seeded when - // the incoming list changes (including the echo of our own committed order). + val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() } + val scope = rememberCoroutineScope() + + // Local working copy; re-seeded when the incoming list changes (including the + // echo of our own committed order). var order by remember(items) { mutableStateOf(items) } var draggedKey by remember { mutableStateOf(null) } - // Translation of the held row from its settled slot; a swap folds one slot - // height back into this so the row stays under the finger across the swap. - var dragOffsetY by remember { mutableStateOf(0f) } - val heights = remember { mutableStateMapOf() } + // Live translation of the held row from its slot (px); also drives the + // release settle, animated back onto a slot before the order is committed. + var dragOffset by remember { mutableFloatStateOf(0f) } + // The running release/cancel settle, cancelled if a new drag pre-empts it. + var settleJob by remember { mutableStateOf(null) } - Column(modifier) { + val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } } + // Whole slots dragged → the slot the held row currently hovers over. + val targetIndex = draggedIndex?.let { + (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) + } + + Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) { order.forEachIndexed { index, item -> val key = keyOf(item) - val isDragging = key == draggedKey + val isDragged = key == draggedKey + + // Slide neighbours by one pitch to open the gap the held row will drop + // into. Snap (not animate) once idle, so committing the new order — which + // moves each row's slot — doesn't visibly fight a lingering animation. + val shift = when { + draggedIndex == null || targetIndex == null || isDragged -> 0f + index in (draggedIndex + 1)..targetIndex -> -pitchPx + index in targetIndex until draggedIndex -> pitchPx + else -> 0f + } + val animatedShift by animateFloatAsState( + targetValue = shift, + animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(), + label = "reorderShift", + ) + val dragHandle = Modifier.pointerInput(key) { detectDragGestures( onDragStart = { + settleJob?.cancel() draggedKey = key - dragOffsetY = 0f + dragOffset = 0f + }, + onDrag = { change, amount -> + change.consume() + dragOffset += amount.y }, onDragEnd = { - draggedKey = null - dragOffsetY = 0f - onReorder(order) + val from = order.indexOfFirst { keyOf(it) == key } + if (from < 0) return@detectDragGestures + val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) + settleJob = scope.launch { + // Settle the held row onto its target slot, then commit — + // resetting offset and slot in the same frame, so nothing jumps. + Animatable(dragOffset).animateTo((to - from) * pitchPx, tween(160)) { + dragOffset = value + } + if (to != from) { + order = order.toMutableList().apply { add(to, removeAt(from)) } + onReorder(order) + } + draggedKey = null + dragOffset = 0f + } }, onDragCancel = { - draggedKey = null - dragOffsetY = 0f - }, - onDrag = { change, dragAmount -> - change.consume() - dragOffsetY += dragAmount.y - val cur = order.indexOfFirst { keyOf(it) == key } - if (cur < 0) return@detectDragGestures - // Swap once the row has travelled past half of the - // neighbour it's heading toward, in the drag direction. - if (dragAmount.y > 0 && cur < order.lastIndex) { - val nextHeight = heights[keyOf(order[cur + 1])] ?: 0 - if (nextHeight > 0 && dragOffsetY > nextHeight / 2f) { - order = order.toMutableList().apply { add(cur + 1, removeAt(cur)) } - dragOffsetY -= nextHeight - } - } else if (dragAmount.y < 0 && cur > 0) { - val prevHeight = heights[keyOf(order[cur - 1])] ?: 0 - if (prevHeight > 0 && dragOffsetY < -prevHeight / 2f) { - order = order.toMutableList().apply { add(cur - 1, removeAt(cur)) } - dragOffsetY += prevHeight - } + settleJob = scope.launch { + Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value } + draggedKey = null + dragOffset = 0f } }, ) } + Box( Modifier - .onSizeChanged { heights[key] = it.height } - .zIndex(if (isDragging) 1f else 0f) - .graphicsLayer { translationY = if (isDragging) dragOffsetY else 0f }, + .height(ReorderableRowHeight) + .zIndex(if (isDragged) 1f else 0f) + .graphicsLayer { + translationY = if (isDragged) dragOffset else animatedShift + if (isDragged) { + scaleX = 1.02f + scaleY = 1.02f + shadowElevation = 8.dp.toPx() + shape = RoundedCornerShape(20.dp) + clip = false + } + }, ) { - rowContent(item, positionOf(index, order.size), dragHandle, isDragging) + rowContent(item, positionOf(index, order.size), dragHandle, isDragged) } } } 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 3c3c97a..8b17a74 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 @@ -107,6 +107,7 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn +import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip @@ -768,7 +769,9 @@ private fun ViewRow( title = stringResource(view.labelRes), position = position, dimmed = dimmed, - minHeight = 64.dp, + minHeight = ReorderableRowHeight, + // The reorderable column owns the inter-row spacing (uniform pitch). + gapBelow = false, container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null, leading = { Icon(