feat(#365): more day and week reminder presets

This commit is contained in:
makiolaj committed 2026-11-13 21:11:19 +01:00
1 parent e9cf7d66ba
commit 2a452125eb
8 files changed
+104 -28

No files matched your search

@@ -89,8 +89,9 @@ fun ReminderDefaultPicker(
// which would strand a hand-entered value with no way to re-check it.
val seenCustom = remember { mutableSetOf<Int>() }
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 },
) {
@@ -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<Int>): List<List<Int>> =
minutes.distinct().sorted().partition { it < DAY }.toList().filter { it.isNotEmpty() }
@StringRes
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
@@ -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<Int>,
alreadyChosen: List<Int>,
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 {
@@ -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
@@ -72,9 +72,6 @@ internal fun reminderChoiceLabel(minutes: List<Int>): 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 =
@@ -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
@@ -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)
@@ -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()
}
}