From 2a452125ebee55224df0f453e6adacbfe2f0987d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 13 Nov 2026 21:11:19 +0100 Subject: [PATCH] feat(#365): more day and week reminder presets --- .../calendula/ui/common/Picker.kt | 33 +++++++++------- .../calendula/ui/common/ReminderFormatting.kt | 18 ++++++++- .../calendula/ui/edit/EventEditScreen.kt | 26 ++++++++----- .../ui/settings/NotificationSettings.kt | 1 + .../calendula/ui/settings/SettingsCommon.kt | 3 -- .../ui/settings/SpecialDatesSettings.kt | 1 + .../calendar/AllDayReminderEncodingTest.kt | 11 +++++- .../ui/common/ReminderPresetsTest.kt | 39 +++++++++++++++++++ 8 files changed, 104 insertions(+), 28 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ReminderPresetsTest.kt 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 f2505b2e..b1a7f427 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 @@ -89,8 +89,9 @@ fun ReminderDefaultPicker( // which would strand a hand-entered value with no way to re-check it. val seenCustom = remember { mutableSetOf() } seenCustom += selectedMinutes.filter { it !in presets } - // Presets plus every custom value seen this session, each as its own row. - val rows = presets + seenCustom.sorted() + // Presets plus every custom value seen this session, each as its own row, + // split into a sub-day and a day-scale group. + val groups = groupReminderLeadTimes(presets + seenCustom) var customExpanded by rememberSaveable { mutableStateOf(false) } var amountText by rememberSaveable { mutableStateOf("") } @@ -125,21 +126,25 @@ fun ReminderDefaultPicker( onClick = { apply(ReminderOverride.None) }, ) 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), - summary = leadTimeSummary?.invoke(minute), - position = positionOf(index, rowCount), - selected = checked, - trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) }, - onClick = { toggle(minute) }, - ) + groups.forEachIndexed { groupIndex, group -> + val isLast = groupIndex == groups.lastIndex + val rowCount = group.size + if (isLast) 1 else 0 // + the custom row + group.forEachIndexed { index, minute -> + val checked = minute in selectedMinutes + GroupedRow( + title = reminderLeadTimeLabel(minute), + summary = leadTimeSummary?.invoke(minute), + position = positionOf(index, rowCount), + selected = checked, + trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) }, + onClick = { toggle(minute) }, + ) + } + if (!isLast) Spacer(Modifier.height(12.dp)) } CustomExpandRow( title = stringResource(R.string.event_edit_reminder_custom), - position = positionOf(rows.size, rowCount), + position = if (groups.isEmpty()) Position.Alone else Position.Bottom, expanded = customExpanded, onExpandedChange = { customExpanded = it }, ) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt index 3812dee8..cea96fca 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt @@ -8,8 +8,24 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.calendar.MINUTES_DEFAULT import de.jeanlucmakiola.floret.reminders.ReminderUnit +private const val DAY = 1_440 +private const val WEEK = 10_080 + +private val DAY_SCALE_PRESETS = + listOf(DAY, 2 * DAY, 3 * DAY, 4 * DAY, 5 * DAY, WEEK, 2 * WEEK, 3 * WEEK) + /** Common reminder lead times offered as quick picks in the form and settings. */ -val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440) +val REMINDER_PRESETS = listOf(0, 10, 30, 60) + DAY_SCALE_PRESETS + +/** Day-scale lead times for all-day events, all-day defaults and contact special dates. */ +val ALLDAY_REMINDER_PRESETS = listOf(0) + DAY_SCALE_PRESETS + +/** + * Sorts lead times and splits them into a sub-day group and a day-scale group, + * so a long picker list reads as two connected sections. Empty groups are dropped. + */ +fun groupReminderLeadTimes(minutes: Collection): List> = + minutes.distinct().sorted().partition { it < DAY }.toList().filter { it.isNotEmpty() } @StringRes fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 86e70e55..42177646 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -143,6 +143,7 @@ import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette +import de.jeanlucmakiola.calendula.ui.common.ALLDAY_REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog @@ -166,6 +167,7 @@ import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS +import de.jeanlucmakiola.calendula.ui.common.groupReminderLeadTimes import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.locale.currentLocale @@ -1488,6 +1490,7 @@ private fun EditSheetHost( ) } EditSheet.Reminder -> ReminderPickerDialog( + presets = if (form.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS, alreadyChosen = form.reminders, onSelect = { minutes -> viewModel.addReminder(minutes) @@ -1580,6 +1583,7 @@ private fun FieldPickerDialog( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReminderPickerDialog( + presets: List, alreadyChosen: List, onSelect: (Int) -> Unit, onDismiss: () -> Unit, @@ -1605,18 +1609,22 @@ private fun ReminderPickerDialog( }, ) { if (!customMode) { - val presets = REMINDER_PRESETS.filterNot { it in alreadyChosen } - val rowCount = presets.size + 1 - presets.forEachIndexed { index, minutes -> - GroupedRow( - title = reminderLeadTimeLabel(minutes), - position = positionOf(index, rowCount), - onClick = { onSelect(minutes) }, - ) + val groups = groupReminderLeadTimes(presets.filterNot { it in alreadyChosen }) + groups.forEachIndexed { groupIndex, group -> + val isLast = groupIndex == groups.lastIndex + val rowCount = group.size + if (isLast) 1 else 0 + group.forEachIndexed { index, minutes -> + GroupedRow( + title = reminderLeadTimeLabel(minutes), + position = positionOf(index, rowCount), + onClick = { onSelect(minutes) }, + ) + } + if (!isLast) Spacer(Modifier.height(12.dp)) } GroupedRow( title = stringResource(R.string.event_edit_reminder_custom), - position = positionOf(rowCount - 1, rowCount), + position = if (groups.isEmpty()) Position.Alone else Position.Bottom, onClick = { customMode = true }, ) } else { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index b6fd7b28..43dc4056 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.common.ALLDAY_REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.DurationPicker import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt index 746ff28d..e484c99d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt @@ -72,9 +72,6 @@ internal fun reminderChoiceLabel(minutes: List): String { return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ") } -/** Day-scale lead times for all-day defaults and contact special dates. */ -internal val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080) - /** A minute-of-day in the app's own 12/24-hour convention ([LocalUse24HourFormat]). */ @Composable internal fun settingsTimeOfDay(minutesOfDay: Int): String = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt index 2f20e9ac..e57b27ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt @@ -29,6 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType +import de.jeanlucmakiola.calendula.ui.common.ALLDAY_REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedRow diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt index 75da13de..31241add 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.data.calendar import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.ui.common.ALLDAY_REMINDER_PRESETS import org.junit.jupiter.api.Test import java.time.LocalDate import java.time.LocalTime @@ -48,11 +49,19 @@ class AllDayReminderEncodingTest { assertThat(actualFire(raw, summer)).isEqualTo(intendedFire(summer, 0, nineAm)) } + @Test + fun `three weeks before fires at 9am local across a DST change`() { + val afterDst = LocalDate.of(2026, 4, 10) // CEST; three weeks earlier is CET + val raw = toProviderAllDayMinutes(3 * 10_080, afterDst, berlin, nineAm) + assertThat(actualFire(raw, afterDst)).isEqualTo(intendedFire(afterDst, 21, nineAm)) + assertThat(fromProviderAllDayMinutes(raw, afterDst, berlin, nineAm)).isEqualTo(3 * 10_080) + } + @Test fun `round-trips for whole-day lead times across both seasons`() { for (date in listOf(summer, winter)) { for (time in listOf(0, nineAm, 20 * 60)) { - for (semantic in listOf(0, 1_440, 2_880, 10_080)) { + for (semantic in ALLDAY_REMINDER_PRESETS) { val raw = toProviderAllDayMinutes(semantic, date, berlin, time) assertThat(fromProviderAllDayMinutes(raw, date, berlin, time)) .isEqualTo(semantic) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ReminderPresetsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ReminderPresetsTest.kt new file mode 100644 index 00000000..f4713ea8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ReminderPresetsTest.kt @@ -0,0 +1,39 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ReminderPresetsTest { + + @Test + fun `presets are sorted and unique`() { + for (presets in listOf(REMINDER_PRESETS, ALLDAY_REMINDER_PRESETS)) { + assertThat(presets).isInStrictOrder() + } + } + + @Test + fun `all-day presets are whole days`() { + assertThat(ALLDAY_REMINDER_PRESETS.all { it % 1_440 == 0 }).isTrue() + } + + @Test + fun `offers two to five days and two and three weeks`() { + val extra = listOf(2_880, 4_320, 5_760, 7_200, 20_160, 30_240) + assertThat(REMINDER_PRESETS).containsAtLeastElementsIn(extra) + assertThat(ALLDAY_REMINDER_PRESETS).containsAtLeastElementsIn(extra) + } + + @Test + fun `groups split sub-day from day-scale lead times, sorted`() { + assertThat(groupReminderLeadTimes(listOf(2_880, 10, 0, 1_440, 45, 10))) + .containsExactly(listOf(0, 10, 45), listOf(1_440, 2_880)).inOrder() + } + + @Test + fun `empty groups are dropped`() { + assertThat(groupReminderLeadTimes(listOf(1_440, 0).drop(1))).containsExactly(listOf(0)) + assertThat(groupReminderLeadTimes(listOf(2_880))).containsExactly(listOf(2_880)) + assertThat(groupReminderLeadTimes(emptyList())).isEmpty() + } +}