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:
commit
a186e5afcd
12 files changed
+1043
-71
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+295
-1
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user