From 959eb599ff5377b310a9b789eef3b0df75b3048f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 12 Sep 2026 13:07:18 +0200 Subject: [PATCH] feat(ring): the ring screen, over the lock screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows over the lock screen, turns it on, keeps it on, and swallows back for the whole lifetime of the window — including while the session is still loading, which is where a back press used to finish the activity and leave the alarm sounding with nothing on screen to stop it. The optional dismiss challenge cannot strand the user: the standing wrong answer clears the moment they start typing the next one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV --- .../clockula/ui/AlarmRingInstrumentedTest.kt | 121 +++++ .../clockula/ui/ring/AlarmRingActivity.kt | 152 ++---- .../clockula/ui/ring/AlarmRingViewModel.kt | 177 +++++++ .../ui/ring/DismissChallengeControls.kt | 127 +++++ .../clockula/ui/ring/RingScreen.kt | 173 +++++++ .../clockula/ui/ring/RingUiState.kt | 37 ++ .../clockula/ui/ring/TimeFormatting.kt | 32 ++ .../ui/ring/AlarmRingViewModelTest.kt | 464 ++++++++++++++++++ 8 files changed, 1183 insertions(+), 100 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmRingInstrumentedTest.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/DismissChallengeControls.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/RingScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/RingUiState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimeFormatting.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmRingInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmRingInstrumentedTest.kt new file mode 100644 index 0000000..6deb3f1 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmRingInstrumentedTest.kt @@ -0,0 +1,121 @@ +package de.jeanlucmakiola.clockula.ui + +import android.content.Context +import android.content.Intent +import android.view.WindowManager +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository +import de.jeanlucmakiola.clockula.domain.AlarmDraft +import de.jeanlucmakiola.clockula.domain.DismissChallenge +import de.jeanlucmakiola.clockula.domain.TimeOfDay +import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * What only a device can answer about the ring screen: that its window really + * keeps the screen on, that a real back press cannot silence an alarm, and that + * the hold challenge is operable without a press-and-hold gesture (D16) — which + * is what keeps it from stranding a screen-reader user. + * + * Compiled in the gate, run on a device (slice plan D18). + */ +@RunWith(AndroidJUnit4::class) +class AlarmRingInstrumentedTest { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Seams { + fun engine(): AlarmEngine + fun alarms(): AlarmRepository + fun states(): AlarmStateRepository + fun wallClock(): WallClock + } + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + private val seams: Seams = + EntryPointAccessors.fromApplication(context.applicationContext, Seams::class.java) + + private var scenario: ActivityScenario? = null + + @After + fun tearDown() { + scenario?.close() + runBlocking { seams.states().clearAll() } + } + + private fun string(id: Int): String = context.getString(id) + + /** Seeds a genuinely ringing alarm through the engine, and returns its id. */ + private fun givenRinging(challenge: DismissChallenge): Long = runBlocking { + val now = seams.wallClock().now() + val id = seams.alarms().create( + AlarmDraft(time = TimeOfDay(7, 0), label = "Work", dismissChallenge = challenge), + ) + seams.engine().onFire(id, now) + id + } + + private fun launch(): ActivityScenario = + ActivityScenario.launch( + Intent(context, AlarmRingActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ).also { scenario = it } + + @Test + fun theRingWindowKeepsTheScreenOn() { + givenRinging(DismissChallenge.NONE) + + var flags = 0 + launch().onActivity { flags = it.window.attributes.flags } + + assertThat(flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON).isNotEqualTo(0) + } + + @Test + fun backCannotSilenceARingingAlarm() { + val id = givenRinging(DismissChallenge.NONE) + val activity = launch() + composeTestRule.waitForIdle() + + activity.onActivity { it.onBackPressedDispatcher.onBackPressed() } + composeTestRule.waitForIdle() + + assertThat(runBlocking { seams.states().state(id).ringingSince }).isNotNull() + } + + @Test + fun anAccessibilityClickCompletesTheHold() { + val id = givenRinging(DismissChallenge.HOLD) + launch() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(string(R.string.ring_challenge_hold)) + .performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.waitForIdle() + + assertThat(runBlocking { seams.states().state(id).ringingSince }).isNull() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt index 030532f..d2fec8e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingActivity.kt @@ -1,136 +1,88 @@ package de.jeanlucmakiola.clockula.ui.ring import android.os.Bundle +import android.view.WindowManager import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint -import de.jeanlucmakiola.clockula.R -import de.jeanlucmakiola.clockula.alarm.AlarmEngine -import de.jeanlucmakiola.clockula.alarm.RingCoordinator -import de.jeanlucmakiola.clockula.domain.alarm.RingSession import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import javax.inject.Inject /** - * The ring screen's **plumbing only** — the window flags that cannot wait, and - * finishing when the ring ends. M4 owns what it looks like; what is here is a - * deliberately plain placeholder so the milestone ships a working path from a - * fired alarm to a dismissed one without pre-empting M4's design. + * The window a firing alarm gets: over the lock screen, with the display turned + * on and kept on, holding [RingScreen] for as long as the alarm is ringing. + * + * The content composes immediately and follows the ViewModel, so nothing waits + * on storage before the screen is up; a session that turns out to be over (the + * notification's Dismiss won the race, or a stale `showIntent` was tapped in the + * status bar) resolves to `Finished` and the window closes itself. + * + * Back is swallowed at the composition root, outside the state `when`: an alarm + * is not dismissed by leaving, and a back press while the session is still being + * read must not drop the user onto the lock screen with the alarm still + * sounding and no on-screen way to silence it. */ @AndroidEntryPoint class AlarmRingActivity : ComponentActivity() { - @Inject - lateinit var engine: AlarmEngine - - @Inject - lateinit var ring: RingCoordinator + private val viewModel: AlarmRingViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) showOverLockScreen() - lifecycleScope.launch { - val session = engine.ringSession() - // Nothing ringing: dismissed from the notification while the screen - // was coming up, or a stale showIntent from the status bar. - if (session == null) { - finish() - return@launch - } - setContent { - ClockulaTheme { RingPlaceholder(session, ::snooze, ::dismiss) } - } - // The ring ending is what closes this screen, whoever ended it. The - // coordinator is seeded null in a fresh process, so storage — not - // memory — is what settles whether the ring is really over. - ring.ringing.collectLatest { alarmId -> - if (alarmId == null && engine.ringSession() == null) finish() + setContent { + ClockulaTheme { + // Installed for *every* state below, Loading included, and + // deliberately without predictive back: there is nothing behind + // this window to preview. + BackHandler(enabled = true) { /* An alarm is not dismissed by leaving. */ } + + val state by viewModel.state.collectAsStateWithLifecycle() + when (val current = state) { + // Nothing to draw yet, but the surface is already the right + // colour, so the window never flashes through. + RingUiState.Loading -> + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} + + is RingUiState.Ringing -> RingScreen( + state = current, + onSnooze = viewModel::onSnooze, + onDismissRequested = viewModel::onDismissRequested, + onAnswerChanged = viewModel::onAnswerChanged, + onSubmitAnswer = viewModel::onSubmitAnswer, + onHoldProgress = viewModel::onHoldProgress, + onEscape = viewModel::onEscape, + ) + + // The ring ending is what closes this screen, whoever ended + // it — the buttons here, the notification, or auto-silence. + RingUiState.Finished -> LaunchedEffect(Unit) { finish() } + } } } } - private fun snooze() { - lifecycleScope.launch { - engine.snooze(sessionAlarmId()) - finish() - } - } - - private fun dismiss() { - lifecycleScope.launch { - engine.dismiss(sessionAlarmId()) - finish() - } - } - - private suspend fun sessionAlarmId(): Long = engine.ringSession()?.alarm?.id ?: 0L - /** * Shown over the lock screen with the display turned on: an alarm the user * has to unlock to silence is not an alarm. `minSdk` is 29, so the * pre-API-27 window flags are not needed. + * + * `FLAG_KEEP_SCREEN_ON` is the "wake lock for the ring duration only" that + * docs/PLAN.md §4 asks for — scoped to this window's life, so unlike a + * `PowerManager.WakeLock` it cannot be leaked past the alarm. */ private fun showOverLockScreen() { setShowWhenLocked(true) setTurnScreenOn(true) - } -} - -/** Stands in for M4's ring screen. No challenge, no motion, no ViewModel — by design. */ -@Composable -private fun RingPlaceholder( - session: RingSession, - onSnooze: () -> Unit, - onDismiss: () -> Unit, -) { - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { - Column( - modifier = Modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "%02d:%02d".format(session.alarm.time.hour, session.alarm.time.minute), - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = session.alarm.label.ifBlank { stringResource(R.string.alarm_notification_title) }, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - modifier = Modifier.padding(top = 32.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (session.canSnooze) { - OutlinedButton(onClick = onSnooze) { - Text(stringResource(R.string.alarm_action_snooze)) - } - } - Button(onClick = onDismiss) { - Text(stringResource(R.string.alarm_action_dismiss)) - } - } - } + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt new file mode 100644 index 0000000..ea3a257 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModel.kt @@ -0,0 +1,177 @@ +package de.jeanlucmakiola.clockula.ui.ring + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.alarm.RingCoordinator +import de.jeanlucmakiola.clockula.domain.DismissChallenge +import de.jeanlucmakiola.clockula.domain.alarm.ChallengeGate +import de.jeanlucmakiola.clockula.domain.alarm.MathProblem +import de.jeanlucmakiola.clockula.domain.alarm.MathProblems +import de.jeanlucmakiola.clockula.domain.alarm.RingSession +import de.jeanlucmakiola.clockula.domain.time.Ticker +import de.jeanlucmakiola.clockula.domain.time.WallClock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds +import javax.inject.Inject + +/** The ring screen's behaviour: the session, the challenge gate, and the two ways out. */ +@HiltViewModel +class AlarmRingViewModel @Inject constructor( + private val engine: AlarmEngine, + private val ring: RingCoordinator, + private val wall: WallClock, + private val ticker: Ticker, +) : ViewModel() { + + private val _state = MutableStateFlow(RingUiState.Loading) + + val state: StateFlow = _state.asStateFlow() + + // The session and the gate are the whole model; [render] is the only place + // they become a UI state, so every path through the screen agrees. + private var session: RingSession? = null + private var gate: ChallengeGate = ChallengeGate.start(DismissChallenge.NONE, seed = 0L) + private var typed: String = "" + private var showedWrong: Boolean = false + + init { + viewModelScope.launch { + val loaded = engine.ringSession() + if (loaded == null) { + _state.value = RingUiState.Finished + return@launch + } + session = loaded + gate = ChallengeGate.start( + loaded.settings.dismissChallenge, + seed = wall.now().toEpochMilliseconds(), + ) + render() + } + // The ring ending is what closes this screen, whoever ended it — but the + // coordinator is seeded null in a fresh process, so storage settles it. + viewModelScope.launch { + ring.ringing.collect { alarmId -> + if (alarmId == null && engine.ringSession() == null) _state.value = RingUiState.Finished + } + } + // The escape earns itself over time, so the gate needs the clock read + // again on a cadence rather than only when the user touches something. + viewModelScope.launch { + ticker.ticks(RING_TICK).collect { + val ringing = session ?: return@collect + if (_state.value !is RingUiState.Ringing) return@collect + gate = gate.ringElapsed(wall.now() - ringing.startedAt) + render() + } + } + } + + /** The Dismiss button. Dismisses iff the gate is already open (i.e. no challenge). */ + fun onDismissRequested() { + if (session == null) return + if (gate.open) dismiss() else render() + } + + /** + * Typing is the answer to the *new* problem, so the "not quite" from the + * previous one stops standing under the field. Left up, half-awake, it reads + * as a rejection of what is being typed right now. + */ + fun onAnswerChanged(text: String) { + if (session == null) return + typed = text + showedWrong = false + render() + } + + /** Correct → dismiss. Wrong → a new problem, the typed answer cleared. */ + fun onSubmitAnswer() { + if (session == null) return + val asked = gate.problem ?: return + gate = gate.answer(typed, nextSeed = nextSeedAfter(asked)) + typed = "" + if (gate.open) { + dismiss() + } else { + showedWrong = true + render() + } + } + + /** Reaching 1f dismisses. */ + fun onHoldProgress(fraction: Float) { + if (session == null) return + gate = gate.hold(fraction) + if (gate.open) dismiss() else render() + } + + /** Dismisses iff the escape is available; otherwise nothing happens. */ + fun onEscape() { + if (session == null) return + gate = gate.escape() + if (gate.open) dismiss() else render() + } + + /** Never gated by the challenge. */ + fun onSnooze() { + val ringing = session ?: return + viewModelScope.launch { + // A refused snooze dismisses inside the engine rather than leaving + // the alarm sounding, so the screen follows it either way. + engine.snooze(ringing.alarm.id) + _state.value = RingUiState.Finished + } + } + + private fun dismiss() { + val ringing = session ?: return + viewModelScope.launch { + engine.dismiss(ringing.alarm.id) + _state.value = RingUiState.Finished + } + } + + /** A new problem is a new problem: a seed that repeats the old one is skipped. */ + private fun nextSeedAfter(asked: MathProblem): Long { + var seed = wall.now().toEpochMilliseconds() + gate.wrongAttempts + 1 + while (MathProblems.generate(seed) == asked) seed++ + return seed + } + + private fun render() { + val ringing = session ?: return + if (_state.value == RingUiState.Finished) return + _state.value = RingUiState.Ringing( + time = ringing.alarm.time, + label = ringing.alarm.label, + canSnooze = ringing.canSnooze, + challenge = challengeState(), + ) + } + + private fun challengeState(): ChallengeUiState = when (gate.kind) { + DismissChallenge.NONE -> ChallengeUiState.Direct + DismissChallenge.MATH -> ChallengeUiState.Math( + prompt = gate.problem?.prompt.orEmpty(), + typed = typed, + wrongAttempts = gate.wrongAttempts, + showedWrong = showedWrong, + escapeAvailable = gate.escapeAvailable, + ) + DismissChallenge.HOLD -> ChallengeUiState.Hold( + progress = gate.holdProgress, + escapeAvailable = gate.escapeAvailable, + ) + } + + private companion object { + /** The escape's countdown only ever needs whole seconds. */ + val RING_TICK = 1.seconds + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/DismissChallengeControls.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/DismissChallengeControls.kt new file mode 100644 index 0000000..c0496a3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/DismissChallengeControls.kt @@ -0,0 +1,127 @@ +package de.jeanlucmakiola.clockula.ui.ring + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.alarm.ChallengeGate +import de.jeanlucmakiola.floret.components.DialogAmountField +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** The maths answer field. Submitting the right sum *is* the dismissal (D14). */ +@Composable +internal fun MathChallengeControls( + state: ChallengeUiState.Math, + onAnswerChanged: (String) -> Unit, + onSubmitAnswer: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.ring_challenge_math_prompt, state.prompt), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + DialogAmountField( + value = state.typed, + onValueChange = onAnswerChanged, + placeholder = stringResource(R.string.ring_challenge_math_hint), + ) + if (state.showedWrong) { + // A wrong answer costs nothing but a new problem: no lockout, no + // penalty timer, no scolding beyond this line. + Text( + text = stringResource(R.string.ring_challenge_wrong), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + Button(onClick = onSubmitAnswer, enabled = state.typed.isNotBlank()) { + Text(stringResource(R.string.alarm_action_dismiss)) + } + } +} + +/** The hold target — press-and-hold, and an accessibility click that completes it (D16). */ +@Composable +internal fun HoldChallengeControls( + state: ChallengeUiState.Hold, + onHoldProgress: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + val label = stringResource(R.string.ring_challenge_hold) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(HOLD_TARGET) + // TalkBack cannot perform a press-and-hold, so an accessibility + // click completes the hold outright. A challenge a screen reader + // cannot answer *is* stranding the person holding the phone. + .semantics(mergeDescendants = true) { + onClick(label = label) { + onHoldProgress(1f) + true + } + } + .pointerInput(Unit) { + detectTapGestures( + onPress = { + coroutineScope { + var completed = false + val ramp = launch { + val start = withFrameNanos { it } + var fraction = 0f + while (fraction < 1f) { + val now = withFrameNanos { it } + fraction = ((now - start).toFloat() / HOLD_NANOS) + .coerceIn(0f, 1f) + onHoldProgress(fraction) + } + completed = true + } + tryAwaitRelease() + ramp.cancel() + // A lifted finger is an abandoned hold, not a + // frozen one — the ring keeps going from zero. + if (!completed) onHoldProgress(0f) + } + }, + ) + }, + ) { + CircularProgressIndicator( + progress = { state.progress }, + modifier = Modifier.size(HOLD_TARGET), + ) + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** Big enough to find half-awake, in the dark, without aiming. */ +private val HOLD_TARGET = 160.dp + +private val HOLD_NANOS = ChallengeGate.HOLD_DURATION.inWholeNanoseconds.toFloat() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/RingScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/RingScreen.kt new file mode 100644 index 0000000..170c7fc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/RingScreen.kt @@ -0,0 +1,173 @@ +package de.jeanlucmakiola.clockula.ui.ring + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Alarm +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.floret.identity.collapseExit +import de.jeanlucmakiola.floret.identity.expandEnter +import de.jeanlucmakiola.floret.identity.rememberReduceMotion + +/** + * The ring screen itself: the time, the label, snooze, and the dismiss + * challenge. + * + * Back is swallowed outright — it must neither silence the alarm nor drop the + * user back onto the lock screen mid-ring — and deliberately *without* + * predictive back: there is nothing behind this window to preview. That handler + * lives at [AlarmRingActivity]'s composition root rather than here, so it is + * installed for every state of the screen including the one before the session + * has been read. + */ +@Composable +internal fun RingScreen( + state: RingUiState.Ringing, + onSnooze: () -> Unit, + onDismissRequested: () -> Unit, + onAnswerChanged: (String) -> Unit, + onSubmitAnswer: () -> Unit, + onHoldProgress: (Float) -> Unit, + onEscape: () -> Unit, + modifier: Modifier = Modifier, +) { + val formatTime = rememberAlarmTimeFormatter() + Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Column( + modifier = Modifier.fillMaxSize().safeDrawingPadding().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RingingBadge() + Text( + text = formatTime(state.time), + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + // A blank label is not a bug: the fallback belongs to the + // screen, so the ViewModel's state stays what storage said. + text = state.label.ifBlank { stringResource(R.string.alarm_notification_title) }, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + AnimatedVisibility( + visible = state.challenge != ChallengeUiState.Direct, + enter = expandEnter(), + exit = collapseExit(), + ) { + when (val challenge = state.challenge) { + ChallengeUiState.Direct -> Unit + is ChallengeUiState.Math -> MathChallengeControls( + state = challenge, + onAnswerChanged = onAnswerChanged, + onSubmitAnswer = onSubmitAnswer, + ) + is ChallengeUiState.Hold -> HoldChallengeControls( + state = challenge, + onHoldProgress = onHoldProgress, + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (state.canSnooze) { + // Never gated by the challenge: the gate guards dismissal, + // and a person who wants nine more minutes has answered + // the only question that matters. + OutlinedButton(onClick = onSnooze) { + Text(stringResource(R.string.alarm_action_snooze)) + } + } else { + Text( + text = stringResource(R.string.ring_snooze_unavailable), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // A solved challenge dismisses on the spot (D14), so the button + // exists only where there is nothing to solve. + if (state.challenge == ChallengeUiState.Direct) { + Button(onClick = onDismissRequested) { + Text(stringResource(R.string.alarm_action_dismiss)) + } + } + } + + // The way out: three wrong answers, or sixty seconds of ringing. + AnimatedVisibility( + visible = state.challenge.escapeAvailable(), + enter = expandEnter(), + exit = collapseExit(), + ) { + TextButton(onClick = onEscape) { + Text(stringResource(R.string.ring_challenge_escape)) + } + } + } + } +} + +/** One pulsing circular badge — the screen's only motion flourish. */ +@Composable +private fun RingingBadge() { + val reduceMotion = rememberReduceMotion() + val pulse = rememberInfiniteTransition(label = "ring-pulse") + val scale by pulse.animateFloat( + initialValue = 1f, + targetValue = if (reduceMotion) 1f else 1.08f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "ring-pulse-scale", + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(96.dp) + .scale(scale) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), + ) { + Icon( + imageVector = Icons.Rounded.Alarm, + // The time and the label below say what this is; the badge is the + // same sentence in a circle. + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(48.dp), + ) + } +} + +private fun ChallengeUiState.escapeAvailable(): Boolean = when (this) { + ChallengeUiState.Direct -> false + is ChallengeUiState.Math -> escapeAvailable + is ChallengeUiState.Hold -> escapeAvailable +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/RingUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/RingUiState.kt new file mode 100644 index 0000000..5e7d2a0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/RingUiState.kt @@ -0,0 +1,37 @@ +package de.jeanlucmakiola.clockula.ui.ring + +import de.jeanlucmakiola.clockula.domain.TimeOfDay + +sealed interface RingUiState { + /** The session has not been read from storage yet. Every action is ignored. */ + data object Loading : RingUiState + + data class Ringing( + val time: TimeOfDay, + /** Verbatim; blank stays blank — the screen supplies the fallback string. */ + val label: String, + val canSnooze: Boolean, + val challenge: ChallengeUiState, + ) : RingUiState + + /** Nothing is ringing any more. The activity finishes. */ + data object Finished : RingUiState +} + +sealed interface ChallengeUiState { + /** No challenge: Dismiss is one tap. */ + data object Direct : ChallengeUiState + + data class Math( + val prompt: String, + val typed: String, + val wrongAttempts: Int, + val showedWrong: Boolean, + val escapeAvailable: Boolean, + ) : ChallengeUiState + + data class Hold( + val progress: Float, + val escapeAvailable: Boolean, + ) : ChallengeUiState +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimeFormatting.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimeFormatting.kt new file mode 100644 index 0000000..6f59c4e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimeFormatting.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.clockula.ui.ring + +import android.text.format.DateFormat +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import de.jeanlucmakiola.clockula.domain.TimeOfDay +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +/** Honours the user's 12/24-hour setting. Deliberately not unit-tested: it is one platform call. */ +@Composable +internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String { + val context = LocalContext.current + // The locale can change under a running activity, and the 12/24-hour + // choice rides the same configuration, so both are re-read with it. + val configuration = LocalConfiguration.current + val formatter = remember(configuration) { + // The device's 12/24-hour choice, then the locale's own short form — + // the same two-step the ring notification already formats with. + if (DateFormat.is24HourFormat(context)) { + DateTimeFormatter.ofPattern("HH:mm") + } else { + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + } + } + return remember(formatter) { + { time -> formatter.format(LocalTime.of(time.hour, time.minute)) } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt new file mode 100644 index 0000000..797c476 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/AlarmRingViewModelTest.kt @@ -0,0 +1,464 @@ +package de.jeanlucmakiola.clockula.ui.ring + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper +import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.domain.DismissChallenge +import de.jeanlucmakiola.clockula.domain.TimeOfDay +import de.jeanlucmakiola.clockula.domain.alarm.ChallengeGate +import de.jeanlucmakiola.clockula.testing.BERLIN +import de.jeanlucmakiola.clockula.testing.FakeAlarmDao +import de.jeanlucmakiola.clockula.testing.FakeAlarmNotifier +import de.jeanlucmakiola.clockula.testing.FakeAlarmScheduler +import de.jeanlucmakiola.clockula.testing.FakeAlarmStateDao +import de.jeanlucmakiola.clockula.testing.FakeRingCoordinator +import de.jeanlucmakiola.clockula.testing.FakeTicker +import de.jeanlucmakiola.clockula.testing.FakeWallClock +import de.jeanlucmakiola.clockula.testing.FakeZoneProvider +import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension +import de.jeanlucmakiola.clockula.testing.RecordingRebootRepair +import de.jeanlucmakiola.clockula.testing.alarmAt +import de.jeanlucmakiola.clockula.testing.instantAt +import de.jeanlucmakiola.clockula.testing.offsetInstant +import de.jeanlucmakiola.floret.prefs.PrefStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.time.Duration.Companion.seconds + +/** + * The ring screen's behaviour over the **real** alarm engine: what a ringing + * alarm looks like, what the challenge gate lets through, and the two ways out + * that mean a half-awake person is never stranded with a screaming phone. + */ +class AlarmRingViewModelTest { + + @JvmField + @RegisterExtension + val main = MainDispatcherExtension() + + private val alarmDao = FakeAlarmDao() + private val stateDao = FakeAlarmStateDao(alarms = alarmDao) + private val wallClock = FakeWallClock(instantAt(BERLIN, "2026-06-10T05:00")) + private val zones = FakeZoneProvider(BERLIN) + private val scheduler = FakeAlarmScheduler() + private val ring = FakeRingCoordinator() + private val notifier = FakeAlarmNotifier() + private val rebootRepair = RecordingRebootRepair() + private val ticker = FakeTicker() + + private val alarms = AlarmRepositoryImpl(alarmDao, wallClock) + private val states = AlarmStateRepositoryImpl(stateDao) + + private val sevenToday = offsetInstant("2026-06-10T07:00+02:00") + + private fun TestScope.engine(tempDir: Path): AlarmEngine = AlarmEngine( + alarms = alarms, + states = states, + settings = SettingsPrefs( + PrefStore( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() }, + ), + ), + ), + scheduler = scheduler, + ring = ring, + notifier = notifier, + rebootRepair = rebootRepair, + wallClock = wallClock, + zones = zones, + ) + + private suspend fun givenRinging( + engine: AlarmEngine, + label: String = "Work", + challenge: DismissChallenge? = null, + snoozeLimit: Int? = null, + ) { + val alarm = alarmAt(id = 1L, hour = 7, minute = 0, label = label, snoozeLimit = snoozeLimit) + .copy(dismissChallenge = challenge) + alarmDao.insert(AlarmMapper.toEntity(alarm)) + wallClock.instant = sevenToday + engine.onFire(1L, sevenToday) + } + + private fun AlarmRingViewModel.ringing(): RingUiState.Ringing = state.value as RingUiState.Ringing + + private fun AlarmRingViewModel.math(): ChallengeUiState.Math = + ringing().challenge as ChallengeUiState.Math + + private fun viewModel(engine: AlarmEngine) = AlarmRingViewModel(engine, ring, wallClock, ticker) + + @Test + fun `nothing ringing settles on finished`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val viewModel = viewModel(engine(tempDir)) + + advanceUntilIdle() + + assertThat(viewModel.state.value).isEqualTo(RingUiState.Finished) + } + + @Test + fun `a ringing alarm shows its time and its label`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, label = "Work") + + val viewModel = viewModel(engine) + advanceUntilIdle() + + assertThat(viewModel.ringing().time to viewModel.ringing().label) + .isEqualTo(TimeOfDay(7, 0) to "Work") + } + + @Test + fun `a blank label is left blank for the screen to fall back on`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, label = "") + + val viewModel = viewModel(engine) + advanceUntilIdle() + + assertThat(viewModel.ringing().label).isEmpty() + } + + @Test + fun `an alarm at its snooze limit cannot be snoozed`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, snoozeLimit = 0) + + val viewModel = viewModel(engine) + advanceUntilIdle() + + assertThat(viewModel.ringing().canSnooze).isFalse() + } + + @Test + fun `no challenge means dismiss is one tap`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.NONE) + + val viewModel = viewModel(engine) + advanceUntilIdle() + + assertThat(viewModel.ringing().challenge).isEqualTo(ChallengeUiState.Direct) + } + + @Test + fun `a maths challenge arrives with a problem and an empty answer`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + + val viewModel = viewModel(engine) + advanceUntilIdle() + + val math = viewModel.math() + assertThat(listOf(math.prompt.isNotBlank(), math.typed.isEmpty(), math.wrongAttempts == 0)) + .containsExactly(true, true, true) + } + + @Test + fun `a hold challenge arrives at zero progress`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.HOLD) + + val viewModel = viewModel(engine) + advanceUntilIdle() + + assertThat(viewModel.ringing().challenge) + .isEqualTo(ChallengeUiState.Hold(progress = 0f, escapeAvailable = false)) + } + + @Test + fun `snooze is never gated by the challenge`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + val viewModel = viewModel(engine) + advanceUntilIdle() + + viewModel.onSnooze() + advanceUntilIdle() + + assertThat(states.state(1L).snoozedUntil).isNotNull() + } + + @Test + fun `snoozing finishes the screen`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + val viewModel = viewModel(engine) + advanceUntilIdle() + + viewModel.onSnooze() + advanceUntilIdle() + + assertThat(viewModel.state.value).isEqualTo(RingUiState.Finished) + } + + @Test + fun `an ungated dismiss stops the ring and finishes the screen`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.NONE) + val viewModel = viewModel(engine) + advanceUntilIdle() + + viewModel.onDismissRequested() + advanceUntilIdle() + + assertThat(states.state(1L).ringingSince to viewModel.state.value) + .isEqualTo(null to RingUiState.Finished) + } + + @Test + fun `dismiss does nothing while the maths gate is closed`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + val viewModel = viewModel(engine) + advanceUntilIdle() + + viewModel.onDismissRequested() + advanceUntilIdle() + + assertThat(states.state(1L).ringingSince).isNotNull() + } + + @Test + fun `typing an answer changes nothing but the answer`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + val viewModel = viewModel(engine) + advanceUntilIdle() + val before = viewModel.math() + + viewModel.onAnswerChanged("8") + advanceUntilIdle() + + assertThat(viewModel.math()).isEqualTo(before.copy(typed = "8")) + } + + @Test + fun `the right answer is itself the dismissal`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + val viewModel = viewModel(engine) + advanceUntilIdle() + viewModel.onAnswerChanged(answerTo(viewModel.math().prompt)) + + viewModel.onSubmitAnswer() + advanceUntilIdle() + + assertThat(states.state(1L).ringingSince to viewModel.state.value) + .isEqualTo(null to RingUiState.Finished) + } + + @Test + fun `a wrong answer replaces the problem and keeps the alarm ringing`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + val viewModel = viewModel(engine) + advanceUntilIdle() + val before = viewModel.math() + viewModel.onAnswerChanged("-1") + + viewModel.onSubmitAnswer() + advanceUntilIdle() + + val after = viewModel.math() + assertThat( + listOf( + after.typed, + after.wrongAttempts, + after.showedWrong, + after.prompt != before.prompt, + states.state(1L).ringingSince != null, + ), + ).containsExactly("", 1, true, true, true).inOrder() + } + + @Test + fun `typing an answer to the new problem clears the standing wrong answer`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + val viewModel = viewModel(engine) + advanceUntilIdle() + viewModel.onAnswerChanged("-1") + viewModel.onSubmitAnswer() + advanceUntilIdle() + assertThat(viewModel.math().showedWrong).isTrue() + + viewModel.onAnswerChanged("4") + advanceUntilIdle() + + // "Not quite" belongs to the problem that was answered, not to the + // one being typed: left standing it reads as a fresh rejection. + val after = viewModel.math() + assertThat(after.showedWrong to after.typed).isEqualTo(false to "4") + } + + @Test + fun `a completed hold is itself the dismissal`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.HOLD) + val viewModel = viewModel(engine) + advanceUntilIdle() + + viewModel.onHoldProgress(1f) + advanceUntilIdle() + + assertThat(states.state(1L).ringingSince to viewModel.state.value) + .isEqualTo(null to RingUiState.Finished) + } + + @Test + fun `an unfinished hold keeps the alarm ringing`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.HOLD) + val viewModel = viewModel(engine) + advanceUntilIdle() + + viewModel.onHoldProgress(0.99f) + advanceUntilIdle() + + val hold = viewModel.ringing().challenge as ChallengeUiState.Hold + assertThat(hold.progress to (states.state(1L).ringingSince != null)) + .isEqualTo(0.99f to true) + } + + @Test + fun `escaping before the escape is offered does nothing`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + val viewModel = viewModel(engine) + advanceUntilIdle() + + viewModel.onEscape() + advanceUntilIdle() + + assertThat(states.state(1L).ringingSince).isNotNull() + } + + @Test + fun `three wrong answers earn a way out`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + val viewModel = viewModel(engine) + advanceUntilIdle() + repeat(ChallengeGate.MAX_WRONG_ATTEMPTS) { + viewModel.onAnswerChanged("-1") + viewModel.onSubmitAnswer() + advanceUntilIdle() + } + + viewModel.onEscape() + advanceUntilIdle() + + assertThat(states.state(1L).ringingSince to viewModel.state.value) + .isEqualTo(null to RingUiState.Finished) + } + + @Test + fun `a minute of ringing earns a way out`(@TempDir tempDir: Path) = runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, challenge = DismissChallenge.MATH) + val viewModel = viewModel(engine) + advanceUntilIdle() + + wallClock.instant = sevenToday + ChallengeGate.ESCAPE_AFTER + 1.seconds + ticker.tick() + advanceUntilIdle() + + assertThat(viewModel.math().escapeAvailable).isTrue() + } + + @Test + fun `a dismissal from elsewhere finishes the screen`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine) + val viewModel = viewModel(engine) + advanceUntilIdle() + + engine.dismiss(1L) + advanceUntilIdle() + + assertThat(viewModel.state.value).isEqualTo(RingUiState.Finished) + } + + @Test + fun `a coordinator that has forgotten the ring does not end it`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine) + val viewModel = viewModel(engine) + advanceUntilIdle() + + ring.stopRinging(1L) + advanceUntilIdle() + + assertThat(viewModel.state.value).isInstanceOf(RingUiState.Ringing::class.java) + } + + @Test + fun `a refused snooze dismisses rather than stranding the alarm`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val engine = engine(tempDir) + givenRinging(engine, snoozeLimit = 0) + val viewModel = viewModel(engine) + advanceUntilIdle() + + viewModel.onSnooze() + advanceUntilIdle() + + val state = states.state(1L) + assertThat(listOf(state.ringingSince, state.snoozedUntil, viewModel.state.value)) + .containsExactly(null, null, RingUiState.Finished) + .inOrder() + } + + @Test + fun `every action is ignored until the session has loaded`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val engine = engine(tempDir) + givenRinging(engine) + advanceUntilIdle() + val viewModel = viewModel(engine) + check(viewModel.state.value == RingUiState.Loading) + + viewModel.onDismissRequested() + viewModel.onSubmitAnswer() + viewModel.onHoldProgress(1f) + viewModel.onEscape() + viewModel.onSnooze() + advanceUntilIdle() + + assertThat(states.state(1L).ringingSince).isNotNull() + } + + /** The prompt is `"47 + 38"`; the answer is what it says. */ + private fun answerTo(prompt: String): String = + prompt.split("+").sumOf { it.trim().toInt() }.toString() +}