fix(alarms): keep the alarm-set notice until a screen has shown it
The usual way to set an alarm is pick, OK, back, and back disposed the editor's snackbar a moment after it appeared. Day taps left within the one-second settle were never announced at all, and a rotation threw a showing snackbar away for good. The announcer is now an app-wide singleton holding the notice as state until a snackbar has run to the end, so the list picks up what the editor could not finish showing. An editor closed mid-settle announces on the way out. Announcing is serialised, so a quick on-then-off cannot land the "on" read last, and a notice nobody showed within ten seconds is dropped rather than shown stale. The engine's one-alarm read now shares upcoming()'s preview helper. Refs #19
This commit is contained in:
1 parent
58c9bfebb1
commit
207475ef37
9 files changed
+225
-135
No files matched your search
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
@@ -187,17 +188,7 @@ class AlarmEngine @Inject constructor(
|
||||
val zone = zones.current()
|
||||
val byId = states.associateBy { it.alarmId }
|
||||
alarms
|
||||
.map { alarm ->
|
||||
// The resolver's state is deliberately discarded: a read must
|
||||
// not write, however much it resolved on the way past.
|
||||
val resolution = AlarmResolver.resolve(
|
||||
alarm,
|
||||
byId[alarm.id] ?: AlarmRingState.initial(alarm.id),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
|
||||
}
|
||||
.map { alarm -> preview(alarm, byId[alarm.id] ?: AlarmRingState.initial(alarm.id), now, zone) }
|
||||
.sortedWith(
|
||||
compareBy<UpcomingAlarm, Instant?>(nullsLast()) { it.nextFire }
|
||||
.thenBy { it.alarm.id },
|
||||
@@ -211,7 +202,15 @@ class AlarmEngine @Inject constructor(
|
||||
*/
|
||||
suspend fun upcomingFor(alarmId: Long): UpcomingAlarm? {
|
||||
val alarm = alarms.find(alarmId) ?: return null
|
||||
val resolution = AlarmResolver.resolve(alarm, states.state(alarmId), wallClock.now(), zones.current())
|
||||
return preview(alarm, states.state(alarmId), wallClock.now(), zones.current())
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolver in preview mode. Its state is deliberately discarded: a read
|
||||
* must not write, however much it resolved on the way past.
|
||||
*/
|
||||
private fun preview(alarm: Alarm, state: AlarmRingState, now: Instant, zone: ZoneId): UpcomingAlarm {
|
||||
val resolution = AlarmResolver.resolve(alarm, state, now, zone)
|
||||
return UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ fun AlarmEditorScreen(
|
||||
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
|
||||
val formatTime = rememberAlarmTimeFormatter()
|
||||
val snackbarHost = remember { SnackbarHostState() }
|
||||
AlarmSetSnackbarEffect(viewModel.snackbars, snackbarHost)
|
||||
AlarmSetSnackbarEffect(viewModel.alarmSetNotice, snackbarHost, viewModel::onAlarmSetNoticeShown)
|
||||
|
||||
// Registered unconditionally, and deliberately **not** inside the
|
||||
// `picker?.let` below: the picker's open flag is ViewModel state that does
|
||||
|
||||
@@ -14,7 +14,6 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
|
||||
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
@@ -47,7 +46,7 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
private val engine: AlarmEngine,
|
||||
private val ringtones: RingtoneCatalog,
|
||||
private val preview: RingtonePreviewer,
|
||||
wall: WallClock,
|
||||
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,
|
||||
@@ -81,17 +80,20 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
/** What the previewer is sounding, so the next preview knows to stop it. */
|
||||
private var previewing: String? = null
|
||||
|
||||
private val announcer = AlarmSetAnnouncer(engine, wall)
|
||||
|
||||
/** The announcement waiting out [ANNOUNCE_SETTLE], so the next change can replace it. */
|
||||
private var announcing: Job? = null
|
||||
|
||||
/** True while [announcing] has not yet announced — what [onCleared] flushes. */
|
||||
private var announcementPending = false
|
||||
|
||||
/**
|
||||
* One-shot: "Alarm set for … from now" after a change that moves when the
|
||||
* alarm rings (#19). Nothing for a switched-off alarm, and nothing for the
|
||||
* label or the sound: those move no fire time.
|
||||
* "Alarm set for … from now" after a change that moves when the alarm rings
|
||||
* (#19). Nothing for a switched-off alarm, and nothing for the label or the
|
||||
* sound: those move no fire time.
|
||||
*/
|
||||
val snackbars: Flow<AlarmSetSnackbar> = announcer.snackbars
|
||||
val alarmSetNotice: Flow<AlarmSetNotice?> = announcer.notice
|
||||
|
||||
fun onAlarmSetNoticeShown(notice: AlarmSetNotice) = announcer.consumed(notice)
|
||||
|
||||
/** Memoised catalog answers, keyed on URI — see [metaOf]. */
|
||||
private val ringtoneMeta = mutableMapOf<String, RingtoneMeta>()
|
||||
@@ -254,6 +256,9 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
labelWrites.value?.let { text ->
|
||||
appScope.launch { alarms.edit(alarmId) { it.copy(label = text) } }
|
||||
}
|
||||
// Back within a second of the last day tap: the announcement still
|
||||
// settling is made now, for the list to show (#19).
|
||||
if (announcementPending) appScope.launch { announcer.announce(alarmId) }
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@@ -287,8 +292,10 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
*/
|
||||
private fun announce(settle: Duration = Duration.ZERO) {
|
||||
announcing?.cancel()
|
||||
announcementPending = true
|
||||
announcing = viewModelScope.launch {
|
||||
delay(settle)
|
||||
announcementPending = false
|
||||
announcer.announce(alarmId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,84 +4,119 @@ import android.content.res.Resources
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmSetCountdown
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** A one-shot for the alarm screens' "Alarm set for … from now" snackbar (#19). */
|
||||
sealed interface AlarmSetSnackbar {
|
||||
data class Show(val countdown: NextFireLabel) : AlarmSetSnackbar
|
||||
|
||||
/** Takes back a "set for" that stopped being true: the alarm was switched off. */
|
||||
data object Hide : AlarmSetSnackbar
|
||||
}
|
||||
/** "Alarm set for … from now", about one alarm, until a screen has shown it (#19). */
|
||||
data class AlarmSetNotice(
|
||||
val alarmId: Long,
|
||||
val countdown: NextFireLabel,
|
||||
val announcedAt: Instant,
|
||||
)
|
||||
|
||||
/**
|
||||
* Says how long until an alarm rings, right after a user action in the app set
|
||||
* it — the moment an AM/PM or wrong-day slip is cheapest to notice (#19).
|
||||
*
|
||||
* Only the list and the editor own one, so an alarm set through the
|
||||
* 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. Not thread-safe; both owners call it from `viewModelScope`.
|
||||
* status-bar icon.
|
||||
*
|
||||
* A **state**, held app-wide, not an event: the notice stays 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 snackbar a moment after it
|
||||
* appeared; a one-shot event would be gone with it, while this one is picked
|
||||
* up again by the list underneath. A rotation mid-snackbar re-shows it the
|
||||
* same way.
|
||||
*/
|
||||
internal class AlarmSetAnnouncer(
|
||||
@Singleton
|
||||
internal class AlarmSetAnnouncer @Inject constructor(
|
||||
private val engine: AlarmEngine,
|
||||
private val wall: WallClock,
|
||||
) {
|
||||
// Dropped, not buffered, with nobody collecting: a countdown that surfaced
|
||||
// minutes later, when the screen came back, would be stale — and about an
|
||||
// action the user has long since moved on from.
|
||||
private val pending = MutableSharedFlow<AlarmSetSnackbar>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
private val current = MutableStateFlow<AlarmSetNotice?>(null)
|
||||
|
||||
val snackbars: Flow<AlarmSetSnackbar> = pending.asSharedFlow()
|
||||
/**
|
||||
* Serialises [announce]: it reads storage and suspends before it writes
|
||||
* [current], and two switch taps in quick succession must not let the
|
||||
* earlier read land last — "set for 9 hours" about an alarm that is off.
|
||||
*/
|
||||
private val lock = Mutex()
|
||||
|
||||
/** The alarm the last [AlarmSetSnackbar.Show] was about, so only it can be taken back. */
|
||||
private var announced: Long? = null
|
||||
/**
|
||||
* The notice to show, or null. Past [FRESH_FOR] it is dropped unshown: a
|
||||
* countdown that surfaced when the user came back to the app an hour
|
||||
* later would be wrong, and about something they did long ago.
|
||||
*/
|
||||
val notice: Flow<AlarmSetNotice?> = current.map { notice ->
|
||||
notice?.takeIf { wall.now() - it.announcedAt < FRESH_FOR }
|
||||
}
|
||||
|
||||
/** Call once the write is stored. Says nothing for an alarm that will not ring. */
|
||||
suspend fun announce(alarmId: Long) {
|
||||
val countdown = AlarmSetCountdown.of(engine.upcomingFor(alarmId), wall.now())
|
||||
when {
|
||||
countdown != null -> {
|
||||
announced = alarmId
|
||||
pending.tryEmit(AlarmSetSnackbar.Show(countdown))
|
||||
}
|
||||
announced == alarmId -> {
|
||||
announced = null
|
||||
pending.tryEmit(AlarmSetSnackbar.Hide)
|
||||
}
|
||||
/**
|
||||
* Call once the write is stored. Reads the alarm as it is now, so it is never
|
||||
* about a superseded value. An alarm that will not ring takes back a notice
|
||||
* still pending about it, and otherwise says nothing.
|
||||
*/
|
||||
suspend fun announce(alarmId: Long): Unit = lock.withLock {
|
||||
val now = wall.now()
|
||||
val countdown = AlarmSetCountdown.of(engine.upcomingFor(alarmId), now)
|
||||
if (countdown != null) {
|
||||
current.value = AlarmSetNotice(alarmId, countdown, announcedAt = now)
|
||||
} else if (current.value?.alarmId == alarmId) {
|
||||
current.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** [notice] was shown until it timed out or was swiped away. */
|
||||
fun consumed(notice: AlarmSetNotice) {
|
||||
current.compareAndSet(notice, null)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Comfortably longer than a short snackbar, so a hand-over between screens fits. */
|
||||
val FRESH_FOR = 10.seconds
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows [snackbars] on [host]. Latest wins: a new countdown replaces the one on
|
||||
* Shows [notice] on [host]. Latest wins: a new countdown replaces the one on
|
||||
* screen rather than queueing behind it, so a burst of changes is one snackbar
|
||||
* that ends up saying the right thing.
|
||||
* that ends up saying the right thing, and a null takes it down.
|
||||
*
|
||||
* [onShown] runs only when the snackbar ended on its own or was swiped away.
|
||||
* One cut short — by leaving the screen, or a rotation — stays pending for the
|
||||
* next screen to show.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AlarmSetSnackbarEffect(snackbars: Flow<AlarmSetSnackbar>, host: SnackbarHostState) {
|
||||
internal fun AlarmSetSnackbarEffect(
|
||||
notice: Flow<AlarmSetNotice?>,
|
||||
host: SnackbarHostState,
|
||||
onShown: (AlarmSetNotice) -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
LaunchedEffect(snackbars, host, resources) {
|
||||
snackbars.collectLatest { snackbar ->
|
||||
when (snackbar) {
|
||||
is AlarmSetSnackbar.Show ->
|
||||
alarmSetMessage(resources, snackbar.countdown)?.let { host.showSnackbar(it) }
|
||||
// The previous Show was cancelled by collectLatest, which already
|
||||
// dismissed it; this covers one shown before a recomposition.
|
||||
AlarmSetSnackbar.Hide -> host.currentSnackbarData?.dismiss()
|
||||
}
|
||||
val shown by rememberUpdatedState(onShown)
|
||||
LaunchedEffect(notice, host, resources) {
|
||||
notice.collectLatest { current ->
|
||||
if (current == null) return@collectLatest
|
||||
val message = alarmSetMessage(resources, current.countdown)
|
||||
if (message != null) host.showSnackbar(message)
|
||||
shown(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ fun AlarmsScreen(
|
||||
// the id to navigate to arrives from the ViewModel (M5 D2, D22).
|
||||
viewModel.openEditor.collectLatest(onCreateAlarm)
|
||||
}
|
||||
AlarmSetSnackbarEffect(viewModel.snackbars, snackbarHost)
|
||||
AlarmSetSnackbarEffect(viewModel.alarmSetNotice, snackbarHost, viewModel::onAlarmSetNoticeShown)
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.tab_alarms),
|
||||
|
||||
@@ -28,6 +28,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()
|
||||
@@ -44,10 +45,13 @@ class AlarmsViewModel @Inject internal constructor(
|
||||
// into two navigations (M5 D22).
|
||||
private val newAlarms = Channel<Long>(Channel.CONFLATED)
|
||||
|
||||
private val announcer = AlarmSetAnnouncer(engine, wall)
|
||||
/**
|
||||
* "Alarm set for … from now", 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
|
||||
|
||||
/** One-shot: "Alarm set for … from now" after the row's switch turns an alarm on (#19). */
|
||||
val snackbars: Flow<AlarmSetSnackbar> = announcer.snackbars
|
||||
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 }
|
||||
|
||||
+82
-37
@@ -23,6 +23,7 @@ import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
@@ -34,6 +35,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
|
||||
@@ -54,7 +56,11 @@ 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 fun TestScope.editor(
|
||||
harness: AlarmEngineHarness,
|
||||
alarmId: Long = 1L,
|
||||
announcer: AlarmSetAnnouncer = AlarmSetAnnouncer(harness.engine, harness.wallClock),
|
||||
) =
|
||||
AlarmEditorViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf(AlarmRoutes.ARG_ALARM_ID to alarmId)),
|
||||
alarms = harness.alarms,
|
||||
@@ -62,7 +68,7 @@ class AlarmEditorViewModelTest {
|
||||
engine = harness.engine,
|
||||
ringtones = catalog,
|
||||
preview = previewer,
|
||||
wall = harness.wallClock,
|
||||
announcer = announcer,
|
||||
appScope = this,
|
||||
)
|
||||
|
||||
@@ -258,6 +264,8 @@ class AlarmEditorViewModelTest {
|
||||
|
||||
// --- "Alarm set for … from now" (#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) {
|
||||
@@ -266,12 +274,10 @@ class AlarmEditorViewModelTest {
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onTimePicked(6, 45)
|
||||
advanceUntilIdle()
|
||||
viewModel.onTimePicked(6, 45)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(1, 45)))
|
||||
}
|
||||
assertThat(viewModel.countdown()).isEqualTo(NextFireLabel.InHoursMinutes(1, 45))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -282,14 +288,12 @@ class AlarmEditorViewModelTest {
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
// The AM/PM slip the snackbar is there to catch: 4:30 is behind
|
||||
// us, so it rings tomorrow, 23½ hours away.
|
||||
viewModel.onTimePicked(4, 30)
|
||||
advanceUntilIdle()
|
||||
// The AM/PM slip the snackbar is there to catch: 4:30 is behind
|
||||
// us, so it rings tomorrow, 23½ hours away.
|
||||
viewModel.onTimePicked(4, 30)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(23, 30)))
|
||||
}
|
||||
assertThat(viewModel.countdown()).isEqualTo(NextFireLabel.InHoursMinutes(23, 30))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -300,12 +304,10 @@ class AlarmEditorViewModelTest {
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onNewAlarmTimeKept()
|
||||
advanceUntilIdle()
|
||||
viewModel.onNewAlarmTimeKept()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(1, 0)))
|
||||
}
|
||||
assertThat(viewModel.countdown()).isEqualTo(NextFireLabel.InHoursMinutes(1, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -316,7 +318,8 @@ class AlarmEditorViewModelTest {
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.alarmSetNotice.test {
|
||||
assertThat(awaitItem()).isNull()
|
||||
viewModel.onDayToggled(DayOfWeek.MONDAY)
|
||||
advanceTimeBy(500)
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
@@ -327,11 +330,29 @@ class AlarmEditorViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
// 2026-06-10 is a Wednesday: 7:00 today, two hours out.
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(2, 0)))
|
||||
assertThat(awaitItem()?.countdown).isEqualTo(NextFireLabel.InHoursMinutes(2, 0))
|
||||
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 = AlarmSetAnnouncer(harness.engine, harness.wallClock)
|
||||
val viewModel = editor(harness, announcer = announcer)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDayToggled(DayOfWeek.MONDAY)
|
||||
advanceTimeBy(200)
|
||||
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) {
|
||||
@@ -341,12 +362,10 @@ class AlarmEditorViewModelTest {
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onSkipNextChanged(true)
|
||||
advanceUntilIdle()
|
||||
viewModel.onSkipNextChanged(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InDaysHours(1, 2)))
|
||||
}
|
||||
assertThat(viewModel.countdown()).isEqualTo(NextFireLabel.InDaysHours(1, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -357,12 +376,10 @@ class AlarmEditorViewModelTest {
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onTimePicked(6, 45)
|
||||
advanceUntilIdle()
|
||||
viewModel.onTimePicked(6, 45)
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
assertThat(viewModel.alarmSetNotice.first()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -373,13 +390,41 @@ class AlarmEditorViewModelTest {
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onLabelTyped("Gym")
|
||||
viewModel.onSnoozeMinutesChanged(5)
|
||||
advanceUntilIdle()
|
||||
viewModel.onLabelTyped("Gym")
|
||||
viewModel.onSnoozeMinutesChanged(5)
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
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()
|
||||
|
||||
harness.wallClock.instant = fiveToday + 1.hours
|
||||
|
||||
assertThat(viewModel.alarmSetNotice.first()).isNull()
|
||||
}
|
||||
|
||||
// --- label ---
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -52,6 +53,7 @@ class AlarmsViewModelTest {
|
||||
wall = harness.wallClock,
|
||||
zones = harness.zones,
|
||||
source = AlarmListSource(harness.engine, harness.wallClock, harness.ticker),
|
||||
announcer = AlarmSetAnnouncer(harness.engine, harness.wallClock),
|
||||
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
|
||||
|
||||
private fun AlarmsUiState.rows(): List<AlarmRowState> = (this as AlarmsUiState.Loaded).rows
|
||||
@@ -198,12 +200,10 @@ class AlarmsViewModelTest {
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onToggleEnabled(1L, enabled = true)
|
||||
advanceUntilIdle()
|
||||
viewModel.onToggleEnabled(1L, enabled = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(2, 12)))
|
||||
}
|
||||
assertThat(viewModel.alarmSetNotice.first()?.countdown).isEqualTo(NextFireLabel.InHoursMinutes(2, 12))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -214,31 +214,29 @@ class AlarmsViewModelTest {
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onToggleEnabled(1L, enabled = true)
|
||||
advanceUntilIdle()
|
||||
skipItems(1)
|
||||
viewModel.onToggleEnabled(1L, enabled = false)
|
||||
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(awaitItem()).isEqualTo(AlarmSetSnackbar.Hide)
|
||||
}
|
||||
assertThat(viewModel.alarmSetNotice.first()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching off an alarm nobody was told about says nothing`(@TempDir tempDir: Path) =
|
||||
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)
|
||||
harness.givenAlarm(id = 1L, hour = 7, enabled = false)
|
||||
harness.givenAlarm(id = 2L, hour = 9)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onToggleEnabled(1L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
viewModel.onToggleEnabled(1L, enabled = true)
|
||||
advanceUntilIdle()
|
||||
viewModel.onToggleEnabled(2L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
assertThat(viewModel.alarmSetNotice.first()?.alarmId).isEqualTo(1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -248,12 +246,10 @@ class AlarmsViewModelTest {
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onAddAlarm()
|
||||
advanceUntilIdle()
|
||||
viewModel.onAddAlarm()
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
assertThat(viewModel.alarmSetNotice.first()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1123,7 +1123,11 @@ only a natural occurrence of an enabled alarm, rounded as the row's countdown
|
||||
is, never a snooze or a ring. The `AlarmClock` intents never reach it, since
|
||||
they already get the status-bar icon (#19). The editor announces a time pick
|
||||
at once, but waits for repeat-day taps to settle for a second, so Mon–Fri is
|
||||
one snackbar rather than five.
|
||||
one snackbar rather than five. `AlarmSetAnnouncer` is an app-wide singleton
|
||||
holding the notice as *state* until a screen has shown it to the end: pick →
|
||||
OK → back disposes the editor's snackbar a moment after it appears, and the
|
||||
list underneath picks the same notice up. A notice older than ten seconds is
|
||||
dropped unshown rather than shown with a stale countdown.
|
||||
|
||||
`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