Merge branch 'feat/19-alarm-set-snackbar' into release/v1.0.0

# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt
#	docs/ARCHITECTURE.md
This commit is contained in:
makiolaj committed 2026-10-26 20:50:55 +01:00
commit a186e5afcd
12 files changed
+1043 -71

No files matched your search

+3
View File
@@ -19,6 +19,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
can play it vibrates.
- An alarm list that shows when each alarm will ring and marks the next one.
Changes apply as you make them, with no Save button.
- Setting an alarm, switching it on or changing its time or repeat days says
how long until it rings, such as "Alarm set for 7 hours and 12 minutes from
now", so an AM/PM or wrong-day slip shows at once.
- Repeat days that start on your locale's first day of the week, and a "skip
next alarm" switch so you can sleep in once without turning the alarm off.
- An "Upcoming alarm" notification two hours before an alarm rings, with a
@@ -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)
}
}
@@ -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<OpenPicker?>(null) }
var pickingTime by rememberSaveable { mutableStateOf(openTimePicker) }
// True until the picker a new alarm opens on is first closed: cancelling
// that one still leaves a freshly set alarm to announce (#19).
var pickingNewAlarm by rememberSaveable { mutableStateOf(openTimePicker) }
TimeCard(time = editing.time, onClick = { pickingTime = true })
Spacer(Modifier.height(16.dp))
@@ -225,8 +237,13 @@ private fun AlarmEditorContent(
onConfirm = { hour, minute ->
viewModel.onTimePicked(hour, minute)
pickingTime = false
pickingNewAlarm = false
},
onDismiss = {
if (pickingNewAlarm) viewModel.onNewAlarmTimeKept()
pickingTime = false
pickingNewAlarm = false
},
onDismiss = { pickingTime = false },
)
}
when (openPicker) {
@@ -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<AlarmSetNotice?> =
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<String, RingtoneMeta>()
@@ -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
@@ -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<Announcement?>(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<AlarmSetNotice?> = 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<AlarmSetNotice?>,
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<String?>(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)
}
@@ -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
@@ -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<AlarmsUiState> = source.rows()
@@ -45,6 +46,14 @@ class AlarmsViewModel @Inject internal constructor(
// into two navigations (M5 D22).
private val newAlarms = Channel<Long>(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<AlarmSetNotice?> = 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<Long> = 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)
}
}
+16
View File
@@ -118,6 +118,22 @@
<string name="alarm_next_in_days_hours">in %1$dd %2$dh</string>
<string name="alarm_next_ringing">Ringing now</string>
<string name="alarm_next_snoozed">Snoozed \u00b7 %1$s</string>
<!-- #19: the chip after the user sets an alarm, short enough to sit beside the FAB. %1$s is alarm_set_days/hours/minutes, or two of them joined by alarm_set_units_pair. -->
<string name="alarm_set_for">Alarm in %1$s</string>
<string name="alarm_set_imminent">Alarm in less than 1 minute</string>
<string name="alarm_set_units_pair">%1$s and %2$s</string>
<plurals name="alarm_set_days">
<item quantity="one">%1$d day</item>
<item quantity="other">%1$d days</item>
</plurals>
<plurals name="alarm_set_hours">
<item quantity="one">%1$d hour</item>
<item quantity="other">%1$d hours</item>
</plurals>
<plurals name="alarm_set_minutes">
<item quantity="one">%1$d minute</item>
<item quantity="other">%1$d minutes</item>
</plurals>
<string name="alarm_skipping_next">Skipping next</string>
<string name="alarm_repeat_once">Once</string>
<string name="alarm_repeat_every_day">Every day</string>
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.alarmAt
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* "Alarm 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()
}
}
@@ -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
@@ -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<AlarmRowState> = (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) {
+18 -2
View File
@@ -86,7 +86,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| Package | Holds |
|---|---|
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent 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<Unit>)` takes its **re-read cadence as a parameter**.
The resolver needs a `now`, and `upcoming` reads one inside a `combine` of the