diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..e0208ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt index d9ff0a9..d567025 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -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() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSetCountdown.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSetCountdown.kt new file mode 100644 index 0000000..3efd675 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSetCountdown.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt index 3e4a3dc..44787a6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt @@ -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(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) { 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 be4879f..cffc860 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 @@ -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 = announcer.snackbars + /** Memoised catalog answers, keyed on URI — see [metaOf]. */ private val ringtoneMeta = mutableMapOf() @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmSetSnackbar.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmSetSnackbar.kt new file mode 100644 index 0000000..d13123a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmSetSnackbar.kt @@ -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( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + val snackbars: Flow = 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, 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt index bdb6c1d..d3ca118 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt index d17e9fe..3152279 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt @@ -44,6 +44,11 @@ class AlarmsViewModel @Inject internal constructor( // into two navigations (M5 D22). private val newAlarms = Channel(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 = announcer.snackbars + /** One-shot: the id of a freshly created alarm, for the caller to navigate to. */ val openEditor: Flow = 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) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..c97e509 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -113,6 +113,22 @@ in %1$dd %2$dh Ringing now Snoozed \u00b7 %1$s + + Alarm set for %1$s from now + Alarm set for less than 1 minute from now + %1$s and %2$s + + %1$d day + %1$d days + + + %1$d hour + %1$d hours + + + %1$d minute + %1$d minutes + Skipping next Once Every day diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSetCountdownTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSetCountdownTest.kt new file mode 100644 index 0000000..411b010 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSetCountdownTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt index 4e9c0b9..4759e80 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt index 1317b97..4fc6f00 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt @@ -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) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..261deb5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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)` takes its **re-read cadence as a parameter**. The resolver needs a `now`, and `upcoming` reads one inside a `combine` of the