diff --git a/CHANGELOG.md b/CHANGELOG.md index 2199d82..b404e1f 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. - An "Upcoming alarm" notification two hours before an alarm rings, with a 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..6eccb85 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmSetCountdown.kt @@ -0,0 +1,24 @@ +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 in 7 hours and 12 minutes" 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 "Alarm in 9 minutes" would read as a mistake. + */ +object AlarmSetCountdown { + /** Pure. The rounding is [NextFireFormat]'s, so the chip and the row agree. */ + fun of(upcoming: UpcomingAlarm?, now: Instant): NextFireLabel? { + // A switched-off alarm resolves to FireSource.NONE, so this one check + // also covers it. + 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..7cfa77e 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 @@ -84,6 +84,15 @@ fun AlarmEditorScreen( onBack = onBack, modifier = modifier, predictiveBack = true, + // In the snackbar slot, so it follows the predictive-back preview, and + // lifted over the live pill, which floats above every screen. + snackbarHost = { + AlarmSetChipBand( + notice = viewModel.alarmSetNotice, + onShown = viewModel::onAlarmSetNoticeShown, + modifier = 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..4ed4f61 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 @@ -18,14 +18,21 @@ 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.CoroutineStart +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock 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 +48,7 @@ class AlarmEditorViewModel @Inject internal constructor( private val engine: AlarmEngine, private val ringtones: RingtoneCatalog, private val preview: RingtonePreviewer, + private val announcer: AlarmSetAnnouncer, // `@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 +82,21 @@ class AlarmEditorViewModel @Inject internal constructor( /** What the previewer is sounding, so the next preview knows to stop it. */ private var previewing: String? = null + /** + * "Alarm in …" 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. Only ever about *this* alarm — the + * countdown names no alarm, so one about the row the user just switched + * on in the list would read as this one's. + */ + val alarmSetNotice: Flow = + announcer.notice.map { notice -> notice?.takeIf { it.alarmId == alarmId } } + + fun onAlarmSetNoticeShown(notice: AlarmSetNotice) = announcer.consumed(notice) + + /** Orders [scheduleWrite]s. */ + private val scheduleWrites = Mutex() + /** Memoised catalog answers, keyed on URI — see [metaOf]. */ private val ringtoneMeta = mutableMapOf() @@ -99,9 +122,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, announces = 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() = announcer.announce(alarmId) + + /** Announced once the taps settle: setting Mon–Fri is one chip, not five (#19). */ + fun onDayToggled(day: DayOfWeek) = edit(reschedule = true, announces = true, settle = ANNOUNCE_SETTLE) { alarm -> val days = if (day in alarm.repeatDays) alarm.repeatDays - day else alarm.repeatDays + day alarm.copy( repeatDays = days, @@ -121,9 +152,14 @@ class AlarmEditorViewModel @Inject internal constructor( } fun onSkipNextChanged(skip: Boolean) { - viewModelScope.launch { + // Absolute, so it must land in tap order against a day toggle that + // disarms the skip: see [scheduleWrite]. + scheduleWrite { 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. + announcer.announce(alarmId) } } @@ -208,7 +244,7 @@ class AlarmEditorViewModel @Inject internal constructor( /** Dismisses a ring first (M5 D11), deletes, and the state becomes [AlarmEditorUiState.Gone]. */ fun onDelete() { - viewModelScope.launch { + scheduleWrite { if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId) alarms.delete(alarmId) engine.reschedule() @@ -235,13 +271,42 @@ 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). [announces] is on for the same changes, after [settle] (#19). + * + * A change that moves a fire time runs on the application scope. Pick → + * OK → back is the usual way to set an alarm, and back clears this + * ViewModel: on its own scope the reschedule — and the "Alarm in …" + * the list is meant to pick up — could be cancelled half-way, leaving a + * stored time the system had not been given yet. */ - private fun edit(reschedule: Boolean = false, transform: (Alarm) -> Alarm) { - viewModelScope.launch { - alarms.edit(alarmId, transform) - if (reschedule) engine.reschedule() + private fun edit( + reschedule: Boolean = false, + announces: Boolean = false, + settle: Duration = Duration.ZERO, + transform: (Alarm) -> Alarm, + ) { + if (!reschedule) { + viewModelScope.launch { alarms.edit(alarmId, transform) } + return } + scheduleWrite { + alarms.edit(alarmId, transform) + engine.reschedule() + if (announces) announcer.announce(alarmId, settle) + } + } + + /** + * A write that moves a fire time, its reschedule and whatever follows, on + * the application scope (see [edit]) and **in tap order**: started + * undispatched, each takes its place in [scheduleWrites]' fair queue on the + * caller's thread, before the first suspension. The application scope is + * multi-threaded, so without it "skip next" followed at once by untapping + * the only repeat day could commit out of order, leaving a one-shot with a + * skip armed — which the resolver answers by disabling it (D14). + */ + private fun scheduleWrite(block: suspend () -> Unit) { + appScope.launch(start = CoroutineStart.UNDISPATCHED) { scheduleWrites.withLock { block() } } } private suspend fun render(inputs: Inputs): AlarmEditorUiState { @@ -313,3 +378,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/AlarmSetChip.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmSetChip.kt new file mode 100644 index 0000000..ee3993b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmSetChip.kt @@ -0,0 +1,295 @@ +package de.jeanlucmakiola.clockula.ui.alarms + +import android.content.res.Resources +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalAccessibilityManager +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +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.ElapsedRealtimeClock +import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.floret.components.SnackChip +import de.jeanlucmakiola.floret.components.SnackChipHeight +import de.jeanlucmakiola.floret.components.SnackChipMargin +import de.jeanlucmakiola.floret.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** "Alarm in …", about one alarm, as a screen should show it (#19). */ +data class AlarmSetNotice( + val alarmId: Long, + val countdown: NextFireLabel, + /** Which announcement this is, so [AlarmSetAnnouncer.consumed] takes back only that one. */ + val sequence: Long, +) + +/** + * 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 call it, so an alarm set through the + * `AlarmClock` intents never announces itself: those already get the + * status-bar icon. + * + * It records only *which* alarm was set, and when. What to say is derived from + * the engine while a screen shows it, so it is never a stale or superseded + * value, and an alarm switched off or deleted by any path — the row's switch, + * the ring screen, a notification, an intent, a restore — says nothing. + * + * App-wide, and a **state** rather than an event: the announcement stands + * until a screen has shown it to the end ([consumed]). The usual way to set an + * alarm is pick → OK → back, and back disposes the editor's chip a moment + * after it appeared; the list underneath picks the same announcement up. The + * settle runs on the application scope for the same reason — leaving the + * editor while its repeat days settle must not lose them. + */ +@Singleton +internal class AlarmSetAnnouncer @Inject constructor( + private val engine: AlarmEngine, + private val wall: WallClock, + private val elapsed: ElapsedRealtimeClock, + // `@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, +) { + /** One user action's announcement. [at] is monotonic: a wall clock set back must not refresh it. */ + private data class Announcement(val alarmId: Long, val at: Duration, val sequence: Long) + + private val current = MutableStateFlow(null) + + /** The request still settling, and the alarm it is about. Guarded by `this`. */ + private var pending: Job? = null + private var pendingFor: Long? = null + private var sequence = 0L + + /** + * The notice to show, or null. + * + * Freshness is judged once, when a screen picks the announcement up: past + * [FRESH_FOR] it is dropped unshown — a countdown surfacing when the user + * came back to the app a while later would be about something they did + * long ago — but one already showing is not cut off half-way. The engine + * is observed only while an announcement stands, and a new announcement + * re-subscribes, so its first read is from after the write that asked. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val notice: Flow = current + .flatMapLatest { announcement -> + 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 chip 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()) + 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 } + } + } + } + } + .distinctUntilChanged() + + /** + * Call once the write is stored. Announces [alarmId] after [settle], unless + * a newer request replaces this one first. Latest wins, across alarms too: + * the chip names no alarm, so a settling announcement about one must + * never land on top of the user's newer action on another and read as + * that one's countdown. + */ + fun announce(alarmId: Long, settle: Duration = Duration.ZERO) { + synchronized(this) { + pending?.cancel() + pendingFor = alarmId + pending = appScope.launch { + delay(settle) + synchronized(this@AlarmSetAnnouncer) { + // A retract or a newer request may have cancelled this one + // after its delay, while it waited for the monitor. + if (isActive) current.value = Announcement(alarmId, elapsed.elapsedRealtime(), ++sequence) + } + } + } + } + + /** + * Takes back anything said or about to be said about [alarmId]. For a + * change of state the engine would read as "still rings" — a dismissal + * that skips the occurrence being counted down to. + */ + fun retract(alarmId: Long) { + synchronized(this) { + if (pendingFor == alarmId) { + pending?.cancel() + pendingFor = null + } + current.update { standing -> standing?.takeUnless { it.alarmId == alarmId } } + } + } + + /** [notice] was shown until it timed out. */ + fun consumed(notice: AlarmSetNotice) = clear(notice.sequence) + + private fun clear(sequence: Long) { + current.update { standing -> standing?.takeUnless { it.sequence == sequence } } + } + + private companion object { + /** Comfortably longer than the chip stands, so a hand-over between screens fits. */ + val FRESH_FOR = 10.seconds + } +} + +/** + * Shows [notice] as the family's [SnackChip] while the screen is resumed, in a + * FAB-height band along the bottom edge. Latest wins: a new countdown replaces + * the one on screen rather than queueing behind it, and a null takes it down. + * + * Resumed, not merely composed: during a navigation cross-fade only the screen + * arriving is resumed, so the two never show it at once, and in the background + * nobody would see it. [onShown] runs only when the chip stood for its full + * time; one cut short — by leaving, a rotation, the app going to the + * background — stays pending for whichever screen resumes next. + * + * The chip carries no timer of its own, so the stand is a short snackbar's, + * stretched the way the accessibility settings ask. + * + * [endClearance] is what the band keeps free at its end for a FAB sharing it; + * the message ellipsises short of it instead of running underneath. + */ +@Composable +internal fun AlarmSetChipBand( + notice: Flow, + onShown: (AlarmSetNotice) -> Unit, + modifier: Modifier = Modifier, + endClearance: Dp = 0.dp, +) { + val resources = LocalResources.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + val accessibility = LocalAccessibilityManager.current + val shown by rememberUpdatedState(onShown) + // Kept after the chip hides, so its exit animation still has words in it. + var message by remember { mutableStateOf(null) } + var visible by remember { mutableStateOf(false) } + LaunchedEffect(notice, resources, lifecycle, accessibility) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + notice.collectLatest { current -> + if (current == null) return@collectLatest + // A notice with nothing to say is used up all the same, so it + // cannot sit pending for ever. + alarmSetMessage(resources, current.countdown)?.let { text -> + message = text + visible = true + try { + delay( + accessibility?.calculateRecommendedTimeoutMillis( + SHOWN_FOR_MILLIS, + containsIcons = false, + containsText = true, + containsControls = false, + ) ?: SHOWN_FOR_MILLIS, + ) + } finally { + visible = false + } + } + shown(current) + } + } + } + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .padding(start = SnackChipMargin, end = SnackChipMargin, bottom = SnackChipMargin) + .height(SnackChipHeight), + contentAlignment = Alignment.CenterStart, + ) { + message?.let { text -> + // The band's full height, as a chip with an Undo stands in the + // other apps; on the secondary container, so it reads apart from + // the alarm cards behind it. + SnackChip( + visible = visible, + message = text, + modifier = Modifier.fillMaxHeight(), + maxWidth = maxWidth - endClearance, + color = MaterialTheme.colorScheme.secondaryContainer, + ) + } + } +} + +/** A short snackbar's stand. */ +private const val SHOWN_FOR_MILLIS = 4_000L + +/** + * 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 ed3a195..cd5d757 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 @@ -1,11 +1,17 @@ package de.jeanlucmakiola.clockula.ui.alarms import android.content.Intent +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -19,6 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -66,65 +73,77 @@ fun AlarmsScreen( viewModel.openEditor.collectLatest(onCreateAlarm) } - CollapsingScaffold( - title = stringResource(R.string.tab_alarms), - modifier = modifier, - scrollable = false, - actions = { - IconButton(onClick = onOpenSettings) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(R.string.settings_title), - ) - } - }, - floatingActionButton = { - FloatingActionButton( - onClick = viewModel::onAddAlarm, - // The pill is bottom-centre and the FAB bottom-end, so on a - // narrow phone they would otherwise overlap (M5 D6). - modifier = Modifier.padding(bottom = pillInset), - ) { - Icon(Icons.Rounded.Add, contentDescription = addAlarm) - } - }, - ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues( - top = AlarmsDefaults.ListBottomPadding, - bottom = AlarmsDefaults.ListBottomPadding + AlarmsDefaults.FabBand + pillInset, - ), + Box(modifier = modifier.fillMaxSize()) { + CollapsingScaffold( + title = stringResource(R.string.tab_alarms), + scrollable = false, + actions = { + IconButton(onClick = onOpenSettings) { + Icon( + imageVector = Icons.Rounded.Settings, + contentDescription = stringResource(R.string.settings_title), + ) + } + }, + floatingActionButton = { + FloatingActionButton( + onClick = viewModel::onAddAlarm, + // The pill is bottom-centre and the FAB bottom-end, so on a + // narrow phone they would otherwise overlap (M5 D6). + modifier = Modifier.padding(bottom = pillInset), + ) { + Icon(Icons.Rounded.Add, contentDescription = addAlarm) + } + }, ) { - if (rows != null && rows.isEmpty()) { - item { AlarmsEmptyState() } - } - items(rows.orEmpty(), key = { row -> row.id }) { row -> - AlarmRow( - row = row, - // A ringing alarm's row leads back to its ring screen, the - // way out if its notification was swiped away. - onClick = { - if (row.ringing) { - context.startActivity( - Intent(context, AlarmRingActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } else { - onEditAlarm(row.id) - } - }, - onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) }, - onDismissComing = { - row.dismissibleFireAt?.let { viewModel.onDismissComing(row.id, it) } - }, - // An alarm whose time changed slides to its new place - // instead of teleporting. - modifier = animateItemMotion(), - ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues( + top = AlarmsDefaults.ListBottomPadding, + bottom = AlarmsDefaults.ListBottomPadding + AlarmsDefaults.FabBand + pillInset, + ), + ) { + if (rows != null && rows.isEmpty()) { + item { AlarmsEmptyState() } + } + items(rows.orEmpty(), key = { row -> row.id }) { row -> + AlarmRow( + row = row, + // A ringing alarm's row leads back to its ring screen, the + // way out if its notification was swiped away. + onClick = { + if (row.ringing) { + context.startActivity( + Intent(context, AlarmRingActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } else { + onEditAlarm(row.id) + } + }, + onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) }, + onDismissComing = { + row.dismissibleFireAt?.let { viewModel.onDismissComing(row.id, it) } + }, + // An alarm whose time changed slides to its new place + // instead of teleporting. + modifier = animateItemMotion(), + ) + } } } + // Beside the FAB, not stacked above it as a snackbar would be: the band is + // the FAB's own, measured from the same insets the scaffold gives it. + AlarmSetChipBand( + notice = viewModel.alarmSetNotice, + onShown = viewModel::onAlarmSetNoticeShown, + endClearance = AlarmsDefaults.FabBand, + modifier = Modifier + .align(Alignment.BottomStart) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) + .padding(bottom = pillInset), + ) } // "Which alarm should I dismiss?" — asked only when a search named more 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 28341c3..0005bef 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 @@ -29,6 +29,7 @@ class AlarmsViewModel @Inject internal constructor( private val wall: WallClock, private val zones: ZoneProvider, private val source: AlarmListSource, + private val announcer: AlarmSetAnnouncer, ) : ViewModel() { val state: StateFlow = source.rows() @@ -45,6 +46,14 @@ class AlarmsViewModel @Inject internal constructor( // into two navigations (M5 D22). private val newAlarms = Channel(Channel.CONFLATED) + /** + * "Alarm in …", after the row's switch turns an alarm on — or + * handed over by the editor the user has just left (#19). + */ + val alarmSetNotice: Flow = announcer.notice + + fun onAlarmSetNoticeShown(notice: AlarmSetNotice) = announcer.consumed(notice) + /** One-shot: the id of a freshly created alarm, for the caller to navigate to. */ val openEditor: Flow = newAlarms.receiveAsFlow().onEach { creating = false } @@ -63,7 +72,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) } @@ -74,6 +84,10 @@ class AlarmsViewModel @Inject internal constructor( // One engine verb, so the contract's dismissal and the user's are the // same code: it closes a ring, arms a repeating alarm's skip, or // disables a one-shot — each of which moves state only the engine owns. + // First, so the chip never re-reads the skip and counts down to the + // occurrence after it. Not re-announced: this is the `AlarmClock` + // contract's request (M9 D13), which the chip stays out of (#19). + announcer.retract(alarmId) viewModelScope.launch { engine.dismissUpcoming(alarmId) } } @@ -92,6 +106,10 @@ class AlarmsViewModel @Inject internal constructor( if (!enabled) stopRingIfRinging(alarmId) alarms.setEnabled(alarmId, enabled) engine.reschedule() + // Switching on says when it will ring (#19). Switching off needs + // nothing: the notice is read from the engine, so one about this + // alarm falls silent by itself, and one about another stands. + if (enabled) announcer.announce(alarmId) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3db934f..2f46517 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,6 +118,22 @@ in %1$dd %2$dh Ringing now Snoozed \u00b7 %1$s + + Alarm in %1$s + Alarm in less than 1 minute + %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..88bb8d4 --- /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 in …" 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 69fe199..f25bd99 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,6 +1,8 @@ package de.jeanlucmakiola.clockula.ui.alarms import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +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 @@ -8,8 +10,10 @@ import de.jeanlucmakiola.clockula.domain.RepeatDays import de.jeanlucmakiola.clockula.domain.RingtoneOption 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.FakeElapsedRealtimeClock import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension @@ -22,8 +26,11 @@ import de.jeanlucmakiola.clockula.testing.instantAt import de.jeanlucmakiola.clockula.testing.offsetInstant import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first 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 @@ -32,6 +39,7 @@ import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir import java.nio.file.Path import java.time.DayOfWeek +import kotlin.time.Duration.Companion.hours /** * The editor over the **real** alarm engine: every control writes at once @@ -52,7 +60,16 @@ class AlarmEditorViewModelTest { private val sevenToday = offsetInstant("2026-06-10T07:00+02:00") private val sevenTomorrow = offsetInstant("2026-06-11T07:00+02:00") - private fun TestScope.editor(harness: AlarmEngineHarness, alarmId: Long = 1L) = + private val elapsed = FakeElapsedRealtimeClock() + + private fun TestScope.announcer(harness: AlarmEngineHarness) = + AlarmSetAnnouncer(harness.engine, harness.wallClock, elapsed, appScope = this) + + private fun TestScope.editor( + harness: AlarmEngineHarness, + alarmId: Long = 1L, + announcer: AlarmSetAnnouncer = announcer(harness), + ) = AlarmEditorViewModel( savedStateHandle = SavedStateHandle(mapOf(AlarmRoutes.ARG_ALARM_ID to alarmId)), alarms = harness.alarms, @@ -60,6 +77,7 @@ class AlarmEditorViewModelTest { engine = harness.engine, ringtones = catalog, preview = previewer, + announcer = announcer, appScope = this, ) @@ -253,6 +271,282 @@ class AlarmEditorViewModelTest { assertThat(viewModel.editing().canSkipNext).isTrue() } + // --- "Alarm in …" (#19) --- + + private suspend fun AlarmEditorViewModel.countdown() = alarmSetNotice.first()?.countdown + + @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.onTimePicked(6, 45) + advanceUntilIdle() + + assertThat(viewModel.countdown()).isEqualTo(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() + + // The AM/PM slip the chip is there to catch: 4:30 is behind + // us, so it rings tomorrow, 23½ hours away. + viewModel.onTimePicked(4, 30) + advanceUntilIdle() + + assertThat(viewModel.countdown()).isEqualTo(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.onNewAlarmTimeKept() + advanceUntilIdle() + + assertThat(viewModel.countdown()).isEqualTo(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.alarmSetNotice.test { + assertThat(awaitItem()).isNull() + 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()?.countdown).isEqualTo(NextFireLabel.InHoursMinutes(2, 0)) + expectNoEvents() + } + } + + @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 chip, 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) { + val harness = alarmEngineHarness(tempDir, now = fiveToday) + harness.givenAlarmWith(hour = 7) + val announcer = announcer(harness) + val viewModel = editor(harness, announcer = announcer) + advanceUntilIdle() + + viewModel.onDayToggled(DayOfWeek.MONDAY) + advanceTimeBy(200) + // What `ViewModel.clear()` does first: the settle must not die with it. + viewModel.viewModelScope.cancel() + viewModel.onCleared() + advanceUntilIdle() + + // Wednesday 05:00 to Monday 07:00. + assertThat(announcer.notice.first()?.countdown).isEqualTo(NextFireLabel.InDaysHours(5, 2)) + } + + @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.onSkipNextChanged(true) + advanceUntilIdle() + + assertThat(viewModel.countdown()).isEqualTo(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.onTimePicked(6, 45) + advanceUntilIdle() + + assertThat(viewModel.alarmSetNotice.first()).isNull() + } + + @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.onLabelTyped("Gym") + viewModel.onSnoozeMinutesChanged(5) + advanceUntilIdle() + + assertThat(viewModel.alarmSetNotice.first()).isNull() + } + + @Test + fun `a notice shown to the end is gone`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir, now = fiveToday) + harness.givenAlarmWith(hour = 7) + val viewModel = editor(harness) + advanceUntilIdle() + viewModel.onTimePicked(6, 45) + advanceUntilIdle() + + viewModel.onAlarmSetNoticeShown(checkNotNull(viewModel.alarmSetNotice.first())) + + assertThat(viewModel.alarmSetNotice.first()).isNull() + } + + @Test + fun `a notice nobody showed in time is dropped, not shown stale`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir, now = fiveToday) + harness.givenAlarmWith(hour = 7) + val viewModel = editor(harness) + advanceUntilIdle() + viewModel.onTimePicked(6, 45) + advanceUntilIdle() + + // Monotonic: a wall clock set back an hour must not make it fresh again. + elapsed.advance(1.hours) + harness.wallClock.instant = fiveToday - 1.hours + + assertThat(viewModel.alarmSetNotice.first()).isNull() + } + + @Test + fun `a notice about another alarm is not shown in this editor`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir, now = fiveToday) + harness.givenAlarmWith(id = 1L, hour = 7) + harness.givenAlarmWith(id = 2L, hour = 22) + val announcer = announcer(harness) + val viewModel = editor(harness, alarmId = 2L, announcer = announcer) + advanceUntilIdle() + + announcer.announce(1L) + advanceUntilIdle() + + assertThat(announcer.notice.first()?.alarmId to viewModel.alarmSetNotice.first()) + .isEqualTo(1L to null) + } + + @Test + fun `deleting the alarm takes its notice back`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir, now = fiveToday) + harness.givenAlarmWith(hour = 7) + val announcer = announcer(harness) + val viewModel = editor(harness, announcer = announcer) + advanceUntilIdle() + viewModel.onTimePicked(6, 45) + advanceUntilIdle() + + viewModel.onDelete() + advanceUntilIdle() + + assertThat(announcer.notice.first()).isNull() + } + + @Test + fun `a ring moved by another path is announced with its new countdown`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir, now = fiveToday) + harness.givenAlarmWith(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val viewModel = editor(harness) + advanceUntilIdle() + viewModel.onTimePicked(7, 0) + advanceUntilIdle() + + // A skip armed from the upcoming-alarm notification: no retract. + harness.alarms.setSkipNextOccurrence(1L, true) + + assertThat(viewModel.countdown()).isEqualTo(NextFireLabel.InDaysHours(1, 2)) + } + + @Test + fun `skip then untapping the only day lands in tap order`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir, now = fiveToday) + harness.givenAlarmWith(hour = 7, repeatDays = RepeatDays.of(DayOfWeek.MONDAY)) + val viewModel = editor(harness) + advanceUntilIdle() + + viewModel.onSkipNextChanged(true) + viewModel.onDayToggled(DayOfWeek.MONDAY) + advanceUntilIdle() + + val stored = harness.alarmDao.stored.single() + assertThat(stored.enabled to stored.skipNextOccurrence).isEqualTo(true to false) + } + + @Test + fun `an alarm switched off by another path is not announced as set`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir, now = fiveToday) + harness.givenAlarmWith(hour = 7) + val viewModel = editor(harness) + advanceUntilIdle() + viewModel.onTimePicked(6, 45) + advanceUntilIdle() + + // The ring screen, a notification or an intent: none of them retracts. + harness.alarms.setEnabled(1L, enabled = false) + + assertThat(viewModel.alarmSetNotice.first()).isNull() + } + // --- 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..f2ad9d4 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 @@ -7,12 +7,14 @@ 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.FakeElapsedRealtimeClock import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension import de.jeanlucmakiola.clockula.testing.RingEvent import de.jeanlucmakiola.clockula.testing.alarmEngineHarness import de.jeanlucmakiola.clockula.testing.instantAt import de.jeanlucmakiola.clockula.testing.offsetInstant import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -24,6 +26,7 @@ import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir import java.nio.file.Path import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds /** * The list over the **real** alarm engine: what the rows say, that the readout @@ -46,12 +49,16 @@ class AlarmsViewModelTest { * `collectAsStateWithLifecycle`; without one there is no subscriber and * nothing upstream would run. */ - private fun TestScope.viewModel(harness: AlarmEngineHarness) = AlarmsViewModel( + private fun TestScope.viewModel( + harness: AlarmEngineHarness, + announcer: AlarmSetAnnouncer = AlarmSetAnnouncer(harness.engine, harness.wallClock, FakeElapsedRealtimeClock(), this), + ) = AlarmsViewModel( alarms = harness.alarms, engine = harness.engine, wall = harness.wallClock, zones = harness.zones, source = AlarmListSource(harness.engine, harness.wallClock, harness.ticker), + announcer = announcer, ).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } } private fun AlarmsUiState.rows(): List = (this as AlarmsUiState.Loaded).rows @@ -190,6 +197,122 @@ 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.onToggleEnabled(1L, enabled = true) + advanceUntilIdle() + + assertThat(viewModel.alarmSetNotice.first()?.countdown).isEqualTo(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() + + // Both taps before either has run: the "on" read must not land last. + viewModel.onToggleEnabled(1L, enabled = true) + viewModel.onToggleEnabled(1L, enabled = false) + advanceUntilIdle() + + assertThat(viewModel.alarmSetNotice.first()).isNull() + } + + @Test + fun `switching off another alarm leaves the announcement standing`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 7, enabled = false) + harness.givenAlarm(id = 2L, hour = 9) + val viewModel = viewModel(harness) + advanceUntilIdle() + + viewModel.onToggleEnabled(1L, enabled = true) + advanceUntilIdle() + viewModel.onToggleEnabled(2L, enabled = false) + advanceUntilIdle() + + assertThat(viewModel.alarmSetNotice.first()?.alarmId).isEqualTo(1L) + } + + @Test + fun `dismissing the announced alarm takes the announcement back`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY, enabled = false) + val viewModel = viewModel(harness) + advanceUntilIdle() + viewModel.onToggleEnabled(1L, enabled = true) + advanceUntilIdle() + + viewModel.onDismissUpcoming(1L) + advanceUntilIdle() + + // Skipped, still on — and still not re-announced: the dismissal is + // the AlarmClock contract's, which the chip stays out of. + assertThat(viewModel.alarmSetNotice.first()).isNull() + } + + @Test + fun `switching another alarm off keeps one still settling`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 7) + harness.givenAlarm(id = 2L, hour = 9) + val announcer = AlarmSetAnnouncer(harness.engine, harness.wallClock, FakeElapsedRealtimeClock(), this) + val viewModel = viewModel(harness, announcer) + advanceUntilIdle() + + // The editor's repeat days on alarm 1, still settling... + announcer.announce(1L, settle = 1.seconds) + // ...when alarm 2 is switched off in the list. + viewModel.onToggleEnabled(2L, enabled = false) + advanceUntilIdle() + + assertThat(viewModel.alarmSetNotice.first()?.alarmId).isEqualTo(1L) + } + + @Test + fun `an announcement that fell silent does not speak up when something else re-enables the alarm`( + @TempDir tempDir: Path, + ) = runTest(main.dispatcher) { + val harness = alarmEngineHarness(tempDir) + harness.givenAlarm(id = 1L, hour = 7, enabled = false) + val viewModel = viewModel(harness) + advanceUntilIdle() + viewModel.onToggleEnabled(1L, enabled = true) + viewModel.onToggleEnabled(1L, enabled = false) + advanceUntilIdle() + check(viewModel.alarmSetNotice.first() == null) + + // An AlarmClock intent switching it back on: intents never announce. + harness.alarms.setEnabled(1L, enabled = true) + + assertThat(viewModel.alarmSetNotice.first()).isNull() + } + + @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.onAddAlarm() + advanceUntilIdle() + + assertThat(viewModel.alarmSetNotice.first()).isNull() + } + @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 094838e..29ceaac 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 and bundled-tone sentinels), `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, `VolumeButtonPolicy` (#18). 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, `VolumeButtonPolicy` (#18), and `AlarmSetCountdown`, what the "Alarm in …" chip 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` | @@ -135,7 +135,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`/`AlarmSetChipBand`, the "Alarm in …" floret `SnackChip` 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` | @@ -1125,6 +1125,22 @@ 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. +The "Alarm in …" chip (#19) is built on `upcoming()` too. +`AlarmSetAnnouncer`, an app-wide singleton, records only *which* alarm a user +action in the list or the editor just set; while a resumed screen shows it, +what it says is derived from `upcoming()`, through `AlarmSetCountdown` (pure, +in `domain/alarm/`): only a natural occurrence of an enabled alarm, rounded as +the row's countdown is, never a snooze or a ring. So an alarm switched off or +deleted by any path is never announced as set, and the `AlarmClock` intents, +which already get the status-bar icon, never reach it. The announcement is +*state*, standing until a screen has shown it to the end: pick → OK → back +disposes the editor's chip a moment after it appears, and the list +underneath picks it up. The editor announces a time pick at once and waits a +second for repeat-day taps to settle, so Mon–Fri is one chip; the settle, +and the editor's fire-time-moving writes with their reschedule, run in tap +order on the application scope, so leaving the editor at once neither loses +the announcement nor leaves a stored time unscheduled. An announcement older +than ten seconds on the monotonic clock is dropped unshown. `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