fix(alarms): word the alarm-set snackbar once per announcement

Every write re-read the engine and re-worded a standing announcement,
so tapping Mon-Fri while the snackbar showed restarted it on each tap,
as did a label keystroke across a minute. The words now come from the
first read; later writes can only take the snackbar down, when the
alarm stops ringing. The settled announcement still replaces it once.

Deleting no longer retracts by hand: a deleted alarm already reads as
nothing to say.

Refs #19
This commit is contained in:
makiolaj committed 2026-10-21 19:51:47 +02:00
1 parent d2e73901c1
commit 706a384704
3 files changed
+43 -12

No files matched your search

@@ -248,8 +248,6 @@ class AlarmEditorViewModel @Inject internal constructor(
if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId)
alarms.delete(alarmId)
engine.reschedule()
// "Set for 2 hours from now" must not outlive the alarm on the list.
announcer.retract(alarmId)
_state.value = AlarmEditorUiState.Gone
}
}
@@ -99,14 +99,24 @@ internal class AlarmSetAnnouncer @Inject constructor(
if (announcement == null || elapsed.elapsedRealtime() - announcement.at >= FRESH_FOR) {
flowOf(null)
} else {
// Worded once, from the first read: later writes — the next
// repeat-day tap, a label keystroke across a minute — must not
// restart a snackbar already showing. They can only take it
// down, when the alarm stops ringing at all.
var worded: AlarmSetNotice? = null
engine.upcoming().map { upcoming ->
val entry = upcoming.firstOrNull { it.alarm.id == announcement.alarmId }
val countdown = AlarmSetCountdown.of(entry, wall.now())
// Nothing to say is the answer for good: an announcement
// left standing would speak up later, long after the
// action, should something else switch the alarm back on.
if (countdown == null) dropIfCurrent(announcement)
countdown?.let { AlarmSetNotice(announcement.alarmId, it, announcement.sequence) }
if (countdown == null) {
// Nothing to say is the answer for good: left standing,
// it would speak up long after the action, should
// something else switch the alarm back on.
clear(announcement.sequence)
null
} else {
worded ?: AlarmSetNotice(announcement.alarmId, countdown, announcement.sequence)
.also { worded = it }
}
}
}
}
@@ -150,12 +160,10 @@ internal class AlarmSetAnnouncer @Inject constructor(
}
/** [notice] was shown until it timed out or was swiped away. */
fun consumed(notice: AlarmSetNotice) {
current.update { standing -> standing?.takeUnless { it.sequence == notice.sequence } }
}
fun consumed(notice: AlarmSetNotice) = clear(notice.sequence)
private fun dropIfCurrent(announcement: Announcement) {
current.update { standing -> standing?.takeUnless { it.sequence == announcement.sequence } }
private fun clear(sequence: Long) {
current.update { standing -> standing?.takeUnless { it.sequence == sequence } }
}
private companion object {
@@ -343,6 +343,31 @@ class AlarmEditorViewModelTest {
}
}
@Test
fun `day taps while a notice shows do not restart it before they settle`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.givenAlarmWith(hour = 7)
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.onTimePicked(9, 0)
advanceUntilIdle()
viewModel.alarmSetNotice.test {
assertThat(awaitItem()?.countdown).isEqualTo(NextFireLabel.InHoursMinutes(4, 0))
viewModel.onDayToggled(DayOfWeek.MONDAY)
advanceTimeBy(300)
viewModel.onDayToggled(DayOfWeek.TUESDAY)
advanceTimeBy(300)
expectNoEvents()
advanceUntilIdle()
// One more snackbar, once settled: Wednesday 05:00 to Monday 09:00.
assertThat(awaitItem()?.countdown).isEqualTo(NextFireLabel.InDaysHours(5, 4))
expectNoEvents()
}
}
@Test
fun `leaving while the days settle still announces them, for the list`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {