fix(alarms): name a pause's last day, and switch on an alarm given a date

"Paused, back on" named the day after the pause, which for a weekday
alarm can be a Saturday it never rings on. A pause under way now reads
"Paused through Wed, Oct 28".

Picking a date on a switched-off alarm switches it on, the pause picker
cannot confirm a range that is already over, a spent pause stored with
its ends reversed is cleared too, and the widget formats its date in the
app's own locale.
This commit is contained in:
2026-10-22 07:53:11 +02:00
parent 1dcd743e5b
commit 247dbcb751
10 changed files with 59 additions and 13 deletions
@@ -43,10 +43,14 @@ abstract class AlarmDao {
)
abstract suspend fun clearScheduledDate(id: Long, expected: Long, updatedAt: Long)
/** Clears the pause only while it is still [expectedStart]..[expectedEnd]. */
/**
* Clears the pause only while it is still [expectedStart]..[expectedEnd],
* in either order: the mapper reads a reversed row as the ordered range.
*/
@Query(
"UPDATE alarms SET pause_start = NULL, pause_end = NULL, updated_at = :updatedAt " +
"WHERE id = :id AND pause_start = :expectedStart AND pause_end = :expectedEnd",
"WHERE id = :id AND MIN(pause_start, pause_end) = :expectedStart " +
"AND MAX(pause_start, pause_end) = :expectedEnd",
)
abstract suspend fun clearPause(id: Long, expectedStart: Long, expectedEnd: Long, updatedAt: Long)
@@ -126,6 +126,9 @@ internal fun PauseRangePickerDialog(
)
val pickedStart = state.selectedStartDateMillis
val pickedEnd = state.selectedEndDateMillis ?: pickedStart
// A begun pause may keep its start in the past, but a range that is over
// before today would pause nothing, so it cannot be confirmed.
val confirmable = pickedEnd != null && !PickerDates.toDate(pickedEnd).isBefore(today)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
@@ -135,7 +138,7 @@ internal fun PauseRangePickerDialog(
onConfirm(PauseRange.ordered(PickerDates.toDate(pickedStart), PickerDates.toDate(pickedEnd)))
}
},
enabled = pickedStart != null,
enabled = confirmable,
) { Text(stringResource(R.string.action_ok)) }
},
dismissButton = { DismissButtons(onRemove = onRemove, onDismiss = onDismiss) },
@@ -146,6 +146,9 @@ class AlarmEditorViewModel @Inject internal constructor(
/**
* null removes the date. A date the picker should not have offered — one
* on which this alarm's time is already past — is refused, not stored.
* Picking a date switches the alarm on: a date is a request for it to
* ring then, and a switched-off alarm would show "Scheduled for" and stay
* silent.
*/
fun onScheduledDateChosen(date: LocalDate?) {
val now = wallClock.now()
@@ -155,7 +158,7 @@ class AlarmEditorViewModel @Inject internal constructor(
alarm.repeatDays.isRepeating -> alarm
date == null -> alarm.copy(scheduledDate = null)
!AlarmSchedule.isAhead(date, alarm.time, now, zone) -> alarm
else -> alarm.copy(scheduledDate = date)
else -> alarm.copy(scheduledDate = date, enabled = true)
}
}
}
@@ -157,16 +157,17 @@ internal fun scheduleSummaryText(row: AlarmRowState): String {
}
/**
* "Paused Oct 26 – 28" while the pause is still ahead, "Paused, back on Thu,
* Oct 29" once it has begun: by then its start is history, and naming the
* pause's last day would read as the day it ends *before*.
* "Paused Oct 26 – 28" while the pause is still ahead, "Paused through Wed,
* Oct 28" once it has begun: by then its start is history. "Through", not
* "until", because the last day is itself paused; and the last day rather than
* the day after, which for a weekday alarm need not be a day it rings.
*/
@Composable
private fun pausedText(pause: PauseRange, begun: Boolean): String =
if (!begun) {
stringResource(R.string.alarm_paused_dates, pauseRangeText(pause))
} else {
stringResource(R.string.alarm_paused_back_on, scheduledDateText(pause.end.plusDays(1)))
stringResource(R.string.alarm_paused_through, scheduledDateText(pause.end))
}
/** Ringing and snoozed say so; anything else is the countdown, or nothing. */
@@ -139,7 +139,7 @@ private fun timeLabel(context: Context, model: NextAlarmWidgetModel): String {
private fun dayLabel(context: Context, model: NextAlarmWidgetModel): String = when {
model.isToday -> context.getString(R.string.widget_next_alarm_today)
model.date != null -> Locale.getDefault().let { locale ->
model.date != null -> currentLocale(context).let { locale ->
// The year only when it is not this one, as the alarm list does.
val skeleton = if (model.dateNeedsYear) "yEEEMMMd" else "EEEMMMd"
DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, skeleton), locale).format(model.date)
+2 -2
View File
@@ -122,8 +122,8 @@
<string name="alarm_scheduled_for">Scheduled for %1$s</string>
<!-- %1$s is a date range such as "Oct 26 – 28" -->
<string name="alarm_paused_dates">Paused %1$s</string>
<!-- %1$s is the first date the alarm rings again, such as "Thu, Oct 29": the pause has already begun -->
<string name="alarm_paused_back_on">Paused, back on %1$s</string>
<!-- %1$s is the pause's last day, such as "Wed, Oct 28", which is itself still paused: the pause has already begun -->
<string name="alarm_paused_through">Paused through %1$s</string>
<string name="alarm_summary_separator" translatable="false">" \u00b7 "</string>
<!-- M5: the alarm editor -->
@@ -107,4 +107,18 @@ class AlarmEngineScheduleTest {
// A read never writes: the ended pause waits for the next reschedule.
assertThat(harness.alarms.find(1L)!!.pause).isEqualTo(pause)
}
@Test
fun `a spent pause stored with its ends reversed is cleared too`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-20T05:00"))
val entity = AlarmMapper.toEntity(alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY)).copy(
pauseStart = LocalDate.of(2026, 6, 12).toEpochDay(),
pauseEnd = LocalDate.of(2026, 6, 10).toEpochDay(),
)
harness.alarmDao.insert(entity)
harness.engine.reschedule()
assertThat(harness.alarmDao.stored.single().pauseStart).isNull()
}
}
@@ -80,7 +80,11 @@ open class FakeAlarmDao(
override suspend fun clearPause(id: Long, expectedStart: Long, expectedEnd: Long, updatedAt: Long) {
rows.update { rows ->
rows.map {
if (it.id == id && it.pauseStart == expectedStart && it.pauseEnd == expectedEnd) {
val start = it.pauseStart
val end = it.pauseEnd
val matches = start != null && end != null &&
minOf(start, end) == expectedStart && maxOf(start, end) == expectedEnd
if (it.id == id && matches) {
it.copy(pauseStart = null, pauseEnd = null, updatedAt = updatedAt)
} else {
it
@@ -279,4 +279,20 @@ class AlarmEditorScheduleTest {
assertThat(harness.scheduler.next?.fireAt).isEqualTo(offsetInstant("2026-06-11T08:00+02:00"))
}
@Test
fun `scheduling a date on a switched-off alarm switches it on`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.given(alarmAt(hour = 7, enabled = false))
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onScheduledDateChosen(june18)
advanceUntilIdle()
assertThat(harness.stored().enabled).isTrue()
assertThat(harness.scheduler.next)
.isEqualTo(Registration(offsetInstant("2026-06-18T07:00+02:00"), 1L))
}
}
+2 -1
View File
@@ -1341,7 +1341,8 @@ date only while its occurrence is still ahead and a pause only while its last
day has not passed. The editor's date picker starts at `AlarmSchedule.earliestDate`,
the date of the alarm's next plain occurrence, so neither a past day nor
today-after-the-time-has-gone can be picked; moving the time earlier than now
on a date set for today drops the date. Picking a repeat day clears the date
on a date set for today drops the date. Picking a date switches the alarm on, since
a switched-off alarm would read "Scheduled for" and stay silent. Picking a repeat day clears the date
and clearing the last one clears the pause, as in Google Clock. The
`AlarmClock` contract cannot express either field, so `SET_ALARM` never
re-uses an alarm that carries one (§17).