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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user