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:
2026-09-12 13:07:18 +02:00
parent 83c5e9fe3f
commit 959eb599ff
8 changed files with 1183 additions and 100 deletions
@@ -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()
}