feat(alarms): ask which alarm, when the request names more than one

"Dismiss my alarm" with two alarms set is a question, not an instruction. When
the search matches more than one the app asks, in a dialog listing the
candidates; when it matches exactly one it acts without asking.

The Timers tab's setup effect no longer re-keys on a list that changes with
every readout tick.
This commit is contained in:
makiolaj committed 2026-09-23 11:02:20 +02:00
1 parent 02f3794ee8
commit 46a049f988
9 files changed
+420 -1

No files matched your search

@@ -345,4 +345,55 @@ class AlarmsViewModelTest {
assertThat(viewModel.state.value.rows().map { it.id }).containsExactly(2L)
}
// --- M9: the AlarmClock contract's dismissal, and the chooser dialog's ---
@Test
fun `dismissing a ringing alarm from the dialog closes the cycle`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
harness.engine.reschedule()
harness.givenRinging(alarmId = 1L, at = sevenToday)
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onDismissUpcoming(1L)
advanceUntilIdle()
assertThat(harness.states.state(1L).ringingSince).isNull()
assertThat(harness.ring.events.last()).isEqualTo(RingEvent.Stopped(1L))
}
@Test
fun `dismissing a repeating alarm only skips its next occurrence`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
harness.engine.reschedule()
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onDismissUpcoming(1L)
advanceUntilIdle()
val alarm = harness.alarms.find(1L)!!
assertThat(alarm.skipNextOccurrence to alarm.enabled).isEqualTo(true to true)
}
@Test
fun `dismissing an id no alarm has writes nothing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
harness.engine.reschedule()
val viewModel = viewModel(harness)
advanceUntilIdle()
val before = harness.alarmDao.stored
viewModel.onDismissUpcoming(404L)
advanceUntilIdle()
assertThat(harness.alarmDao.stored).isEqualTo(before)
}
}
@@ -0,0 +1,55 @@
package de.jeanlucmakiola.clockula.ui.alarms
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import org.junit.jupiter.api.Test
/**
* The chooser dialog filters the list already on screen, so the question and
* the list behind it agree — one source, one order, no second ticker (M9 D13).
*/
class DismissChooserTest {
private fun row(id: Long, hour: Int) = AlarmRowState(
id = id,
time = TimeOfDay(hour, 0),
label = "",
enabled = true,
repeatDays = RepeatDays.NONE,
nextFire = NextFireLabel.NotScheduled,
snoozed = false,
ringing = false,
skippingNext = false,
isNext = false,
)
private val rows = listOf(row(1L, 6), row(2L, 7), row(3L, 8))
@Test
fun `the chosen rows keep the list's own order, not the candidates'`() {
assertThat(DismissChooser.rowsFor(rows, listOf(3L, 1L)).map { it.id })
.containsExactly(1L, 3L).inOrder()
}
@Test
fun `a candidate the list does not hold is skipped`() {
assertThat(DismissChooser.rowsFor(rows, listOf(2L, 99L)).map { it.id }).containsExactly(2L)
}
@Test
fun `no candidates means no rows`() {
assertThat(DismissChooser.rowsFor(rows, emptyList())).isEmpty()
}
@Test
fun `a duplicated candidate appears once`() {
assertThat(DismissChooser.rowsFor(rows, listOf(2L, 2L, 2L)).map { it.id }).containsExactly(2L)
}
@Test
fun `an empty list answers with nothing and throws nothing`() {
assertThat(DismissChooser.rowsFor(emptyList(), listOf(1L, 2L))).isEmpty()
}
}