feat(#365): more day and week reminder presets
This commit is contained in:
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
|
||||
|
||||
+10
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user