feat(ring): the ring screen, over the lock screen
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
@@ -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<AlarmRingActivity>? = 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<AlarmRingActivity> =
|
||||
ActivityScenario.launch<AlarmRingActivity>(
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(RingUiState.Loading)
|
||||
|
||||
val state: StateFlow<RingUiState> = _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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user