diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt index 26a209f..8af9f12 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt index 0210ce9..cd90c84 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmDatePickers.kt @@ -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) }, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt index 87d45f8..e897411 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt @@ -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) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt index 0f254be..4cc6cb0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt @@ -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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt index fad435d..eb05606 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/widget/NextAlarmWidgetContent.kt @@ -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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9aef65a..051ebd3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,8 +122,8 @@ Scheduled for %1$s Paused %1$s - - Paused, back on %1$s + + Paused through %1$s " \u00b7 " diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt index c0dc523..4db1d81 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineScheduleTest.kt @@ -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() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt index 8c6f1e5..4469e06 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScheduleTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScheduleTest.kt index ba02a3d..e8b1d94 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScheduleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScheduleTest.kt @@ -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)) + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fe334f7..3a3260a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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).