feat(alarms): say how long until an alarm rings when you set it

After a user action in the app sets an alarm, a snackbar says how long
until it rings: "Alarm set for 7 hours and 12 minutes from now". The row
already said "in 7h 12m", but nothing drew the eye to it, so an AM/PM or
wrong-day slip went unnoticed until the morning.

It is raised by switching an alarm on in the list, and in the editor by
picking a time (or keeping a new alarm's default one), arming or
disarming the skip, and changing the repeat days. Day taps wait a second
to settle, so Mon-Fri is one snackbar rather than five; a newer
countdown always replaces the one on screen. Switching the announced
alarm back off takes it back. A switched-off, ringing or snoozed alarm
says nothing, and the AlarmClock intents never reach it, since those
already get the status-bar icon.

AlarmSetCountdown decides what it says, purely, with the row's rounding;
the engine gains upcomingFor(), a read-only resolve of one alarm.

Fixes #19
This commit is contained in:
2026-10-21 18:51:49 +02:00
parent 3f431a7f32
commit 58c9bfebb1
13 changed files with 540 additions and 8 deletions
+3
View File
@@ -19,6 +19,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
can play it vibrates.
- An alarm list that shows when each alarm will ring and marks the next one.
Changes apply as you make them, with no Save button.
- Setting an alarm, switching it on or changing its time or repeat days says
how long until it rings, such as "Alarm set for 7 hours and 12 minutes from
now", so an AM/PM or wrong-day slip shows at once.
- Repeat days that start on your locale's first day of the week, and a "skip
next alarm" switch so you can sleep in once without turning the alarm off.
- Per-alarm sound, vibration, gradual volume, snooze length and limit, and a
@@ -204,6 +204,17 @@ class AlarmEngine @Inject constructor(
)
}
/**
* One alarm's [upcoming] entry, resolved against the wall clock now. Read-only
* like [upcoming], and for the same reason it takes no lock: it writes
* nothing. Null for an id no alarm has.
*/
suspend fun upcomingFor(alarmId: Long): UpcomingAlarm? {
val alarm = alarms.find(alarmId) ?: return null
val resolution = AlarmResolver.resolve(alarm, states.state(alarmId), wallClock.now(), zones.current())
return UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
}
private suspend fun rescheduleLocked(): ScheduledFire? {
val now = wallClock.now()
val zone = zones.current()
@@ -0,0 +1,23 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.format.NextFireFormat
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import kotlin.time.Instant
/**
* What "Alarm set for 7 hours and 12 minutes from now" says once the user has
* set an alarm, or null when it should say nothing (#19).
*
* Only a natural occurrence is announced. A switched-off alarm will not ring at
* all; a ringing or snoozed one is answering the snooze, not the change the
* user just made, and "set for 9 minutes from now" would read as a mistake.
*/
object AlarmSetCountdown {
/** Pure. The rounding is [NextFireFormat]'s, so the snackbar and the row agree. */
fun of(upcoming: UpcomingAlarm?, now: Instant): NextFireLabel? {
if (upcoming == null || !upcoming.alarm.enabled) return null
if (upcoming.source != FireSource.OCCURRENCE) return null
val nextFire = upcoming.nextFire ?: return null
return NextFireFormat.labelFor(nextFire, now)
}
}
@@ -11,6 +11,8 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -18,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -59,6 +62,8 @@ fun AlarmEditorScreen(
val previewPlaying by viewModel.previewPlaying.collectAsStateWithLifecycle()
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
val formatTime = rememberAlarmTimeFormatter()
val snackbarHost = remember { SnackbarHostState() }
AlarmSetSnackbarEffect(viewModel.snackbars, snackbarHost)
// Registered unconditionally, and deliberately **not** inside the
// `picker?.let` below: the picker's open flag is ViewModel state that does
@@ -84,6 +89,10 @@ fun AlarmEditorScreen(
onBack = onBack,
modifier = modifier,
predictiveBack = true,
// Lifted over the live pill, which floats above every screen.
snackbarHost = {
SnackbarHost(snackbarHost, Modifier.padding(bottom = LocalLivePillInset.current))
},
actions = {
if (state is AlarmEditorUiState.Editing) {
IconButton(onClick = { confirmingDelete = true }) {
@@ -144,6 +153,9 @@ private fun AlarmEditorContent(
// about losing a half-entered time).
var openPicker by rememberSaveable { mutableStateOf<OpenPicker?>(null) }
var pickingTime by rememberSaveable { mutableStateOf(openTimePicker) }
// True until the picker a new alarm opens on is first closed: cancelling
// that one still leaves a freshly set alarm to announce (#19).
var pickingNewAlarm by rememberSaveable { mutableStateOf(openTimePicker) }
TimeCard(time = editing.time, onClick = { pickingTime = true })
Spacer(Modifier.height(16.dp))
@@ -225,8 +237,13 @@ private fun AlarmEditorContent(
onConfirm = { hour, minute ->
viewModel.onTimePicked(hour, minute)
pickingTime = false
pickingNewAlarm = false
},
onDismiss = {
if (pickingNewAlarm) viewModel.onNewAlarmTimeKept()
pickingTime = false
pickingNewAlarm = false
},
onDismiss = { pickingTime = false },
)
}
when (openPicker) {
@@ -14,10 +14,14 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -26,6 +30,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.time.DayOfWeek
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* The editor writes as the user goes: no Save, no dirty state, no discard
@@ -41,6 +47,7 @@ class AlarmEditorViewModel @Inject internal constructor(
private val engine: AlarmEngine,
private val ringtones: RingtoneCatalog,
private val preview: RingtonePreviewer,
wall: WallClock,
// `@param:` — the qualifier has no explicit @Target, and K2 resolves a bare
// annotation on a constructor `val` to the property instead.
@param:ApplicationScope private val appScope: CoroutineScope,
@@ -74,6 +81,18 @@ class AlarmEditorViewModel @Inject internal constructor(
/** What the previewer is sounding, so the next preview knows to stop it. */
private var previewing: String? = null
private val announcer = AlarmSetAnnouncer(engine, wall)
/** The announcement waiting out [ANNOUNCE_SETTLE], so the next change can replace it. */
private var announcing: Job? = null
/**
* One-shot: "Alarm set for … from now" after a change that moves when the
* alarm rings (#19). Nothing for a switched-off alarm, and nothing for the
* label or the sound: those move no fire time.
*/
val snackbars: Flow<AlarmSetSnackbar> = announcer.snackbars
/** Memoised catalog answers, keyed on URI — see [metaOf]. */
private val ringtoneMeta = mutableMapOf<String, RingtoneMeta>()
@@ -99,9 +118,17 @@ class AlarmEditorViewModel @Inject internal constructor(
/** Clamped, never throws. */
fun onTimePicked(hour: Int, minute: Int) =
edit(reschedule = true) { it.copy(time = TimeOfDay.clamped(hour, minute)) }
edit(reschedule = true, announce = true) { it.copy(time = TimeOfDay.clamped(hour, minute)) }
fun onDayToggled(day: DayOfWeek) = edit(reschedule = true) { alarm ->
/**
* The picker a new alarm opens on was closed without a time. The alarm
* exists and is enabled at its default hour all the same — this is the
* moment it was set, so it is announced (#19).
*/
fun onNewAlarmTimeKept() = announce()
/** Announced once the taps settle: setting Mon–Fri is one snackbar, not five (#19). */
fun onDayToggled(day: DayOfWeek) = edit(reschedule = true, announce = true, settle = ANNOUNCE_SETTLE) { alarm ->
val days = if (day in alarm.repeatDays) alarm.repeatDays - day else alarm.repeatDays + day
alarm.copy(
repeatDays = days,
@@ -124,6 +151,9 @@ class AlarmEditorViewModel @Inject internal constructor(
viewModelScope.launch {
alarms.setSkipNextOccurrence(alarmId, skip)
engine.reschedule()
// Skipping moves the next ring a day or more out, which is exactly
// the kind of jump worth saying out loud.
announce()
}
}
@@ -235,12 +265,31 @@ class AlarmEditorViewModel @Inject internal constructor(
/**
* Every editor write. [reschedule] is on only for a change that can move a
* fire time — so fiddling with sound settings costs no AlarmManager traffic
* (M5 D12).
* (M5 D12). [announce] is on for the same changes, after [settle].
*/
private fun edit(reschedule: Boolean = false, transform: (Alarm) -> Alarm) {
private fun edit(
reschedule: Boolean = false,
announce: Boolean = false,
settle: Duration = Duration.ZERO,
transform: (Alarm) -> Alarm,
) {
viewModelScope.launch {
alarms.edit(alarmId, transform)
if (reschedule) engine.reschedule()
if (announce) announce(settle)
}
}
/**
* Replaces any announcement still settling, so a burst of changes ends in
* one snackbar about where they landed. Read from storage when it fires,
* not from the change that asked, so it is never about a superseded value.
*/
private fun announce(settle: Duration = Duration.ZERO) {
announcing?.cancel()
announcing = viewModelScope.launch {
delay(settle)
announcer.announce(alarmId)
}
}
@@ -313,3 +362,6 @@ class AlarmEditorViewModel @Inject internal constructor(
val accessMayLapse: Boolean,
)
}
/** How long the repeat days must stay untouched before the editor says when the alarm rings. */
private val ANNOUNCE_SETTLE: Duration = 1.seconds
@@ -0,0 +1,111 @@
package de.jeanlucmakiola.clockula.ui.alarms
import android.content.res.Resources
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalResources
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.domain.alarm.AlarmSetCountdown
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
/** A one-shot for the alarm screens' "Alarm set for … from now" snackbar (#19). */
sealed interface AlarmSetSnackbar {
data class Show(val countdown: NextFireLabel) : AlarmSetSnackbar
/** Takes back a "set for" that stopped being true: the alarm was switched off. */
data object Hide : AlarmSetSnackbar
}
/**
* Says how long until an alarm rings, right after a user action in the app set
* it — the moment an AM/PM or wrong-day slip is cheapest to notice (#19).
*
* Only the list and the editor own one, so an alarm set through the
* `AlarmClock` intents never announces itself: those already get the
* status-bar icon. Not thread-safe; both owners call it from `viewModelScope`.
*/
internal class AlarmSetAnnouncer(
private val engine: AlarmEngine,
private val wall: WallClock,
) {
// Dropped, not buffered, with nobody collecting: a countdown that surfaced
// minutes later, when the screen came back, would be stale — and about an
// action the user has long since moved on from.
private val pending = MutableSharedFlow<AlarmSetSnackbar>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val snackbars: Flow<AlarmSetSnackbar> = pending.asSharedFlow()
/** The alarm the last [AlarmSetSnackbar.Show] was about, so only it can be taken back. */
private var announced: Long? = null
/** Call once the write is stored. Says nothing for an alarm that will not ring. */
suspend fun announce(alarmId: Long) {
val countdown = AlarmSetCountdown.of(engine.upcomingFor(alarmId), wall.now())
when {
countdown != null -> {
announced = alarmId
pending.tryEmit(AlarmSetSnackbar.Show(countdown))
}
announced == alarmId -> {
announced = null
pending.tryEmit(AlarmSetSnackbar.Hide)
}
}
}
}
/**
* Shows [snackbars] on [host]. Latest wins: a new countdown replaces the one on
* screen rather than queueing behind it, so a burst of changes is one snackbar
* that ends up saying the right thing.
*/
@Composable
internal fun AlarmSetSnackbarEffect(snackbars: Flow<AlarmSetSnackbar>, host: SnackbarHostState) {
val resources = LocalResources.current
LaunchedEffect(snackbars, host, resources) {
snackbars.collectLatest { snackbar ->
when (snackbar) {
is AlarmSetSnackbar.Show ->
alarmSetMessage(resources, snackbar.countdown)?.let { host.showSnackbar(it) }
// The previous Show was cancelled by collectLatest, which already
// dismissed it; this covers one shown before a recomposition.
AlarmSetSnackbar.Hide -> host.currentSnackbarData?.dismiss()
}
}
}
}
/**
* The full sentence: proper plurals, and the unit pairs the row's countdown
* uses — minutes, hours and minutes, or days and hours. A zero second unit is
* left out, as the row leaves it out: "7 hours", not "7 hours and 0 minutes".
*/
internal fun alarmSetMessage(resources: Resources, countdown: NextFireLabel): String? {
fun days(n: Int) = resources.getQuantityString(R.plurals.alarm_set_days, n, n)
fun hours(n: Int) = resources.getQuantityString(R.plurals.alarm_set_hours, n, n)
fun minutes(n: Int) = resources.getQuantityString(R.plurals.alarm_set_minutes, n, n)
fun pair(first: String, second: String?) =
if (second == null) first else resources.getString(R.string.alarm_set_units_pair, first, second)
val span = when (countdown) {
NextFireLabel.NotScheduled -> return null
NextFireLabel.Imminent -> return resources.getString(R.string.alarm_set_imminent)
is NextFireLabel.InMinutes -> minutes(countdown.minutes)
is NextFireLabel.InHoursMinutes ->
pair(hours(countdown.hours), countdown.minutes.takeIf { it > 0 }?.let(::minutes))
is NextFireLabel.InDaysHours ->
pair(days(countdown.days), countdown.hours.takeIf { it > 0 }?.let(::hours))
}
return resources.getString(R.string.alarm_set_for, span)
}
@@ -15,10 +15,13 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -59,12 +62,14 @@ fun AlarmsScreen(
val context = LocalContext.current
val pillInset = LocalLivePillInset.current
val addAlarm = stringResource(R.string.alarms_add)
val snackbarHost = remember { SnackbarHostState() }
LaunchedEffect(viewModel) {
// The FAB creates the alarm and the editor edits a persisted row, so
// the id to navigate to arrives from the ViewModel (M5 D2, D22).
viewModel.openEditor.collectLatest(onCreateAlarm)
}
AlarmSetSnackbarEffect(viewModel.snackbars, snackbarHost)
CollapsingScaffold(
title = stringResource(R.string.tab_alarms),
@@ -78,6 +83,8 @@ fun AlarmsScreen(
)
}
},
// The scaffold stacks it above the FAB, which already clears the pill.
snackbarHost = { SnackbarHost(snackbarHost) },
floatingActionButton = {
FloatingActionButton(
onClick = viewModel::onAddAlarm,
@@ -44,6 +44,11 @@ class AlarmsViewModel @Inject internal constructor(
// into two navigations (M5 D22).
private val newAlarms = Channel<Long>(Channel.CONFLATED)
private val announcer = AlarmSetAnnouncer(engine, wall)
/** One-shot: "Alarm set for … from now" after the row's switch turns an alarm on (#19). */
val snackbars: Flow<AlarmSetSnackbar> = announcer.snackbars
/** One-shot: the id of a freshly created alarm, for the caller to navigate to. */
val openEditor: Flow<Long> = newAlarms.receiveAsFlow().onEach { creating = false }
@@ -62,7 +67,8 @@ class AlarmsViewModel @Inject internal constructor(
val time = AlarmDefaults.nextWholeHour(wall.now(), zones.current())
val id = alarms.create(AlarmDraft(time = time))
// Created enabled, so it schedules at once — which is what tapping
// "+" asked for (M5 D22).
// "+" asked for (M5 D22). Not announced here: the editor opens on
// its time picker, and announces whichever time the user keeps.
engine.reschedule()
newAlarms.send(id)
}
@@ -82,6 +88,9 @@ class AlarmsViewModel @Inject internal constructor(
if (!enabled) stopRingIfRinging(alarmId)
alarms.setEnabled(alarmId, enabled)
engine.reschedule()
// Switching on says when it will ring; switching off takes back a
// "set for" still on screen about this alarm (#19).
announcer.announce(alarmId)
}
}
+16
View File
@@ -113,6 +113,22 @@
<string name="alarm_next_in_days_hours">in %1$dd %2$dh</string>
<string name="alarm_next_ringing">Ringing now</string>
<string name="alarm_next_snoozed">Snoozed \u00b7 %1$s</string>
<!-- #19: the snackbar after the user sets an alarm. %1$s is alarm_set_days/hours/minutes, or two of them joined by alarm_set_units_pair. -->
<string name="alarm_set_for">Alarm set for %1$s from now</string>
<string name="alarm_set_imminent">Alarm set for less than 1 minute from now</string>
<string name="alarm_set_units_pair">%1$s and %2$s</string>
<plurals name="alarm_set_days">
<item quantity="one">%1$d day</item>
<item quantity="other">%1$d days</item>
</plurals>
<plurals name="alarm_set_hours">
<item quantity="one">%1$d hour</item>
<item quantity="other">%1$d hours</item>
</plurals>
<plurals name="alarm_set_minutes">
<item quantity="one">%1$d minute</item>
<item quantity="other">%1$d minutes</item>
</plurals>
<string name="alarm_skipping_next">Skipping next</string>
<string name="alarm_repeat_once">Once</string>
<string name="alarm_repeat_every_day">Every day</string>
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.alarmAt
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* "Alarm set for … from now" speaks only for an alarm that will ring at a
* natural occurrence, and rounds the way the row's countdown does (#19).
*/
class AlarmSetCountdownTest {
private fun upcoming(
nextFire: Instant?,
source: FireSource = FireSource.OCCURRENCE,
enabled: Boolean = true,
) = UpcomingAlarm(alarmAt(hour = 7, enabled = enabled), nextFire, source)
@Test
fun `an alarm due in hours and minutes says both`() {
val countdown = AlarmSetCountdown.of(upcoming(T0 + 7.hours + 12.minutes), now = T0)
assertThat(countdown).isEqualTo(NextFireLabel.InHoursMinutes(7, 12))
}
@Test
fun `under a minute away is imminent`() {
val countdown = AlarmSetCountdown.of(upcoming(T0 + 40.seconds), now = T0)
assertThat(countdown).isEqualTo(NextFireLabel.Imminent)
}
@Test
fun `minutes away rounds up, as the row does`() {
val countdown = AlarmSetCountdown.of(upcoming(T0 + 4.minutes + 1.seconds), now = T0)
assertThat(countdown).isEqualTo(NextFireLabel.InMinutes(5))
}
@Test
fun `more than a day away says days and hours`() {
val countdown = AlarmSetCountdown.of(upcoming(T0 + 2.days + 3.hours), now = T0)
assertThat(countdown).isEqualTo(NextFireLabel.InDaysHours(2, 3))
}
@Test
fun `a switched-off alarm says nothing`() {
val countdown = AlarmSetCountdown.of(upcoming(nextFire = null, FireSource.NONE, enabled = false), now = T0)
assertThat(countdown).isNull()
}
@Test
fun `a snoozed alarm says nothing about its snooze`() {
val countdown = AlarmSetCountdown.of(upcoming(T0 + 9.minutes, FireSource.SNOOZE), now = T0)
assertThat(countdown).isNull()
}
@Test
fun `a ringing alarm says nothing`() {
val countdown = AlarmSetCountdown.of(upcoming(nextFire = null, FireSource.RESUMED_RING), now = T0)
assertThat(countdown).isNull()
}
@Test
fun `an unknown alarm says nothing`() {
assertThat(AlarmSetCountdown.of(upcoming = null, now = T0)).isNull()
}
}
@@ -1,12 +1,14 @@
package de.jeanlucmakiola.clockula.ui.alarms
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
@@ -23,6 +25,7 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
@@ -59,6 +62,7 @@ class AlarmEditorViewModelTest {
engine = harness.engine,
ringtones = catalog,
preview = previewer,
wall = harness.wallClock,
appScope = this,
)
@@ -252,6 +256,132 @@ class AlarmEditorViewModelTest {
assertThat(viewModel.editing().canSkipNext).isTrue()
}
// --- "Alarm set for … from now" (#19) ---
@Test
fun `picking a time says how long until it rings`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.givenAlarmWith(hour = 7)
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.snackbars.test {
viewModel.onTimePicked(6, 45)
advanceUntilIdle()
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(1, 45)))
}
}
@Test
fun `a time picked earlier than now says it rings tomorrow`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.givenAlarmWith(hour = 7)
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.snackbars.test {
// The AM/PM slip the snackbar is there to catch: 4:30 is behind
// us, so it rings tomorrow, 23½ hours away.
viewModel.onTimePicked(4, 30)
advanceUntilIdle()
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(23, 30)))
}
}
@Test
fun `keeping a new alarm's default time still says when it rings`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.givenAlarmWith(hour = 6)
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.snackbars.test {
viewModel.onNewAlarmTimeKept()
advanceUntilIdle()
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(1, 0)))
}
}
@Test
fun `a burst of repeat-day taps is announced once, after it settles`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.givenAlarmWith(hour = 7)
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.snackbars.test {
viewModel.onDayToggled(DayOfWeek.MONDAY)
advanceTimeBy(500)
viewModel.onDayToggled(DayOfWeek.TUESDAY)
advanceTimeBy(500)
viewModel.onDayToggled(DayOfWeek.WEDNESDAY)
advanceTimeBy(500)
expectNoEvents()
advanceUntilIdle()
// 2026-06-10 is a Wednesday: 7:00 today, two hours out.
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(2, 0)))
expectNoEvents()
}
}
@Test
fun `arming a skip says it rings at the occurrence after it`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.givenAlarmWith(hour = 7, repeatDays = RepeatDays.EVERY_DAY)
harness.engine.reschedule()
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.snackbars.test {
viewModel.onSkipNextChanged(true)
advanceUntilIdle()
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InDaysHours(1, 2)))
}
}
@Test
fun `a switched-off alarm's new time says nothing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.givenAlarm(id = 1L, hour = 7, enabled = false)
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.snackbars.test {
viewModel.onTimePicked(6, 45)
advanceUntilIdle()
expectNoEvents()
}
}
@Test
fun `a change that moves no fire time says nothing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir, now = fiveToday)
harness.givenAlarmWith(hour = 7)
val viewModel = editor(harness)
advanceUntilIdle()
viewModel.snackbars.test {
viewModel.onLabelTyped("Gym")
viewModel.onSnoozeMinutesChanged(5)
advanceUntilIdle()
expectNoEvents()
}
}
// --- label ---
@Test
@@ -190,6 +190,72 @@ class AlarmsViewModelTest {
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sevenToday)
}
@Test
fun `switching an alarm on says how long until it rings`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, minute = 12, enabled = false)
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.snackbars.test {
viewModel.onToggleEnabled(1L, enabled = true)
advanceUntilIdle()
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(2, 12)))
}
}
@Test
fun `switching the announced alarm back off takes the announcement back`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7, enabled = false)
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.snackbars.test {
viewModel.onToggleEnabled(1L, enabled = true)
advanceUntilIdle()
skipItems(1)
viewModel.onToggleEnabled(1L, enabled = false)
advanceUntilIdle()
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Hide)
}
}
@Test
fun `switching off an alarm nobody was told about says nothing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.snackbars.test {
viewModel.onToggleEnabled(1L, enabled = false)
advanceUntilIdle()
expectNoEvents()
}
}
@Test
fun `adding an alarm leaves the announcement to the editor`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir)
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.snackbars.test {
viewModel.onAddAlarm()
advanceUntilIdle()
expectNoEvents()
}
}
@Test
fun `switching off the ringing alarm silences it first`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
+10 -2
View File
@@ -86,7 +86,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| Package | Holds |
|---|---|
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent sentinel), `RepeatSummary.kt`, `AlarmDefaults.kt`. Plain Kotlin, no Android |
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 |
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate, and `AlarmSetCountdown`, what the "Alarm set for … from now" snackbar says (#19). The *shared* ring vocabulary moved out to `domain/ring/` in M6 |
| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
| `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` |
| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
@@ -133,7 +133,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is gone: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller |
| `ui/common/` | `rememberLocalTimeFormatter`, the app's one 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17); and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need |
| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6), and M9's `DismissChooser`/`DismissAlarmDialog`, the "which alarm should I dismiss?" question |
| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6), and M9's `DismissChooser`/`DismissAlarmDialog`, the "which alarm should I dismiss?" question; and `AlarmSetAnnouncer`/`AlarmSetSnackbar`, the "Alarm set for … from now" snackbar both ViewModels raise after a user action (#19) |
| `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor |
| `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` |
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
@@ -1116,6 +1116,14 @@ its own writes. `reschedule()` is called explicitly: from `ClockulaApp`, from
the receivers, and from the alarm editor. `upcoming()` runs the same resolver in
preview mode and *discards* the state it returns; a read must not write, and a
test asserts the store recorded nothing across a full collection.
`upcomingFor(alarmId)` is the same read for one alarm, on demand: the list and
the editor ask it right after a user's change to say how long until that alarm
rings. `AlarmSetCountdown` (pure, in `domain/alarm/`) decides what that says —
only a natural occurrence of an enabled alarm, rounded as the row's countdown
is, never a snooze or a ring. The `AlarmClock` intents never reach it, since
they already get the status-bar icon (#19). The editor announces a time pick
at once, but waits for repeat-day taps to settle for a second, so Mon–Fri is
one snackbar rather than five.
`upcoming(refresh: Flow<Unit>)` takes its **re-read cadence as a parameter**.
The resolver needs a `now`, and `upcoming` reads one inside a `combine` of the