feat(stopwatch): the tab — the readout, the laps, and the fastest and slowest

A fixed readout over a lap list, newest first, the lap in progress counting
as its own row once there is a lap to compare it against. The fastest and
the slowest are marked in `primary` and `tertiary` — never `error`, and never
by colour alone: each carries a spoken marker so the emphasis survives being
read aloud.

The state rebuilds only what actually moves: the recorded rows and their
emphasis come off the lap table once, and the tick recomputes the hero
figure and the row in progress. Laps stop at 999 and the readout ticks only
while the stopwatch is running.
This commit is contained in:
2026-09-22 10:23:54 +02:00
parent 2aaae5dde8
commit 7b727e0d40
11 changed files with 1272 additions and 26 deletions
@@ -0,0 +1,144 @@
package de.jeanlucmakiola.clockula.ui
import android.content.Context
import android.content.Intent
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso
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.MainActivity
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.domain.Lap
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* §5.14 — 4 cases. What only a device can answer about the Stopwatch tab: that
* a real clock really moves the hero readout, that Lap really appends rows,
* that the tab survives a real activity recreation because its state is storage
* and not a `rememberSaveable`, and that Back lands on Alarms.
*
* Compiled in the gate; **not run here** — no device is attached (§5.14).
*/
@RunWith(AndroidJUnit4::class)
class StopwatchInstrumentedTest {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Seams {
fun stopwatch(): StopwatchRepository
}
@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<MainActivity>? = null
@After
fun tearDown() {
scenario?.close()
runBlocking { seams.stopwatch().reset() }
}
private fun string(id: Int): String = context.getString(id)
private fun storedLaps(): List<Lap> = runBlocking { seams.stopwatch().laps().first() }
private fun openStopwatchTab(): ActivityScenario<MainActivity> {
val launched = ActivityScenario.launch<MainActivity>(
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
).also { scenario = it }
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.tab_stopwatch)).performClick()
composeTestRule.waitForIdle()
return launched
}
private fun startAndLap(times: Int) {
composeTestRule.onNodeWithText(string(R.string.stopwatch_start)).performClick()
composeTestRule.waitForIdle()
repeat(times) {
composeTestRule.onNodeWithText(string(R.string.stopwatch_lap)).performClick()
composeTestRule.waitForIdle()
}
}
/** §5.14 #1 */
@Test
fun startingReallyAdvancesTheReadoutAndOffersPauseAndLap() {
openStopwatchTab()
composeTestRule.onNodeWithText(string(R.string.stopwatch_start)).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.stopwatch_pause)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.stopwatch_lap)).assertIsDisplayed()
// Polled, never slept on: the hero figure has to leave zero by itself.
composeTestRule.waitUntil(timeoutMillis = 5_000) {
composeTestRule.onAllNodesWithText("0:00", substring = true)
.fetchSemanticsNodes()
.isEmpty()
}
}
/** §5.14 #2 */
@Test
fun twoTapsOnLapAppendTwoRowsAndLeaveAThirdInProgress() {
openStopwatchTab()
startAndLap(times = 2)
assertThat(storedLaps().map { it.index }).containsExactly(1, 2).inOrder()
composeTestRule
.onNodeWithContentDescription(context.getString(R.string.stopwatch_lap_number, 3), substring = true)
.assertIsDisplayed()
}
/** §5.14 #3 */
@Test
fun theReadoutAndTheLapListSurviveAnActivityRecreation() {
val launched = openStopwatchTab()
startAndLap(times = 2)
launched.recreate()
composeTestRule.waitForIdle()
assertThat(storedLaps()).hasSize(2)
composeTestRule.onNodeWithText(string(R.string.stopwatch_laps_header_total)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.stopwatch_pause)).assertIsDisplayed()
}
/** §5.14 #4 */
@Test
fun backFromTheStopwatchTabLandsOnAlarms() {
openStopwatchTab()
Espresso.pressBack()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.alarms_empty)).assertIsDisplayed()
assertThat(scenario?.state).isNotEqualTo(Lifecycle.State.DESTROYED)
}
}
@@ -0,0 +1,133 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.StopwatchFormat
import de.jeanlucmakiola.clockula.domain.stopwatch.LapEmphasis
import de.jeanlucmakiola.clockula.ui.theme.ClockulaReadoutDefaults
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
/** The three columns the rows below line up under, in the grouped list's own inset. */
@Composable
internal fun LapColumnHeader(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset + 20.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HeaderCell(R.string.stopwatch_laps_header_lap, INDEX_WEIGHT, TextAlign.Start)
HeaderCell(R.string.stopwatch_laps_header_split, FIGURE_WEIGHT, TextAlign.End)
HeaderCell(R.string.stopwatch_laps_header_total, FIGURE_WEIGHT, TextAlign.End)
}
}
/**
* One lap as a **three-column table** — index · split · total — and therefore a
* bespoke `Row` rather than a `GroupedRow`: `ListItem` has one trailing slot
* and a table has two figures (D10). Every figure uses the tabular `Figure`
* role, so the columns do not jitter as the digits change.
*
* Best is `primary` and worst is `tertiary` — both scheme tokens, and `error`
* is deliberately not used, because a slow lap is not a failure. Colour is not
* the only channel: the row carries a description naming what it is, since a
* distinction a screen reader cannot hear is not a distinction (D7).
*/
@Composable
internal fun LapRow(row: LapRowState, position: Position, modifier: Modifier = Modifier) {
val split = StopwatchFormat.precise(row.split)
val total = StopwatchFormat.precise(row.cumulative)
val description = stringResource(descriptionFor(row), row.index, split, total)
GroupedSurface(
position = position,
modifier = modifier.padding(horizontal = GroupedListInset),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 14.dp)
.semantics(mergeDescendants = true) { contentDescription = description },
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val color = colorFor(row.emphasis)
FigureCell(
text = stringResource(R.string.stopwatch_lap_number, row.index),
weight = INDEX_WEIGHT,
align = TextAlign.Start,
color = color,
)
FigureCell(text = split, weight = FIGURE_WEIGHT, align = TextAlign.End, color = color)
FigureCell(text = total, weight = FIGURE_WEIGHT, align = TextAlign.End, color = color)
}
}
}
@Composable
private fun RowScope.HeaderCell(
label: Int,
weight: Float,
align: TextAlign,
) {
Text(
text = stringResource(label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = align,
modifier = Modifier.weight(weight),
)
}
@Composable
private fun RowScope.FigureCell(
text: String,
weight: Float,
align: TextAlign,
color: Color,
) {
Text(
text = text,
style = ClockulaReadoutDefaults.Figure,
color = color,
textAlign = align,
modifier = Modifier.weight(weight),
)
}
/** A slow lap is not a failure, so `error` is not in this list (D7). */
@Composable
private fun colorFor(emphasis: LapEmphasis): Color = when (emphasis) {
LapEmphasis.BEST -> MaterialTheme.colorScheme.primary
LapEmphasis.WORST -> MaterialTheme.colorScheme.tertiary
LapEmphasis.NONE -> MaterialTheme.colorScheme.onSurface
}
private fun descriptionFor(row: LapRowState): Int = when {
row.kind == LapKind.IN_PROGRESS -> R.string.stopwatch_lap_row_in_progress
row.emphasis == LapEmphasis.BEST -> R.string.stopwatch_lap_row_best
row.emphasis == LapEmphasis.WORST -> R.string.stopwatch_lap_row_worst
else -> R.string.stopwatch_lap_row
}
/** Three digits of index against two figures: the cap is a layout fact too (D14). */
private const val INDEX_WEIGHT = 1f
private const val FIGURE_WEIGHT = 1.5f
@@ -0,0 +1,13 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
internal object StopwatchDefaults {
/** ~60 Hz — the display's own cadence, and it only runs while running (D9). */
val ReadoutTick: Duration = 16.milliseconds
val ListBottomPadding: Dp = 8.dp
}
@@ -0,0 +1,175 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.format.StopwatchFormat
import de.jeanlucmakiola.clockula.ui.theme.ClockulaReadoutDefaults
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
import kotlin.time.Duration
/**
* The hero readout and the controls, fixed above the lap list: scrolling a long
* list must never put Lap out of reach mid-run (D10).
*
* The readout is **two strings** — the seconds at the hero scale and the
* hundredths two ranks down, baseline-aligned (D5). A readout whose hundredths
* are the same size as its seconds makes the fastest-changing glyphs the
* loudest ones; splitting it is `PLAN.md` §8's "refinement comes from shape,
* colour, space and motion, never from enlarging text" applied to the one
* surface §8 says the design lives on.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun StopwatchReadoutPanel(
mode: StopwatchState,
elapsed: Duration,
canLap: Boolean,
onStart: () -> Unit,
onPause: () -> Unit,
onLap: () -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier,
) {
val readout = StopwatchFormat.readout(elapsed)
// The whole figure in one phrase: two Texts would otherwise be announced as
// two unrelated numbers.
val description = stringResource(R.string.stopwatch_readout, readout.text)
GroupedSurface(
position = Position.Alone,
modifier = modifier.padding(horizontal = GroupedListInset),
color = containerFor(mode),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(
// So 9:59 becoming 10:00 eases rather than jumps — the pill's
// own treatment.
modifier = Modifier
.animateContentSizeMotion()
.semantics(mergeDescendants = true) { contentDescription = description },
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = readout.major,
style = ClockulaReadoutDefaults.Hero,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = readout.hundredths,
style = ClockulaReadoutDefaults.HeroFraction,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 6.dp),
)
}
StopwatchControls(
mode = mode,
canLap = canLap,
onStart = onStart,
onPause = onPause,
onLap = onLap,
onReset = onReset,
)
}
}
}
/**
* Start · Pause/Lap · Resume/Reset (D13). The word "Stop" is deliberately not
* used: in Clockula "Stop" already means "reset" — the timer's expired row and
* the live pill both say so — and a Stop that paused would be the one
* inconsistency in the app's verbs.
*
* An idle stopwatch offers **one** button: a Lap with nothing to lap and a
* Reset with nothing to reset are dead controls. Reset is not offered while
* running; the user pauses first, which is Google Clock's arrangement and the
* reference `PLAN.md` §11 names.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun StopwatchControls(
mode: StopwatchState,
canLap: Boolean,
onStart: () -> Unit,
onPause: () -> Unit,
onLap: () -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier,
) {
val lapLimit = stringResource(R.string.stopwatch_lap_limit)
ButtonGroup(modifier = modifier) {
val primaryInteraction = remember { MutableInteractionSource() }
val primary = when (mode) {
StopwatchState.RUNNING -> R.string.stopwatch_pause to onPause
StopwatchState.PAUSED -> R.string.stopwatch_resume to onStart
StopwatchState.IDLE -> R.string.stopwatch_start to onStart
}
Button(
onClick = primary.second,
interactionSource = primaryInteraction,
// `animateWidth` is the expressive press-widening — the reason the
// pair is a ButtonGroup rather than two buttons in a Row.
modifier = Modifier.animateWidth(primaryInteraction),
) {
Text(stringResource(primary.first))
}
if (mode != StopwatchState.IDLE) {
val secondaryInteraction = remember { MutableInteractionSource() }
val lapping = mode == StopwatchState.RUNNING
FilledTonalButton(
onClick = if (lapping) onLap else onReset,
enabled = !lapping || canLap,
interactionSource = secondaryInteraction,
modifier = Modifier
.animateWidth(secondaryInteraction)
// A disabled button says nothing about why: the cap is the
// reason, so the button carries it (D14).
.semantics { if (lapping && !canLap) contentDescription = lapLimit },
) {
Text(stringResource(if (lapping) R.string.stopwatch_lap else R.string.stopwatch_reset))
}
}
}
}
/**
* The same tonal step `TimerRow` uses to say "this one is live". No
* `tertiaryContainer` anywhere: in this app that token means "finished and
* asking for you", and a stopwatch never finishes (D24).
*/
@Composable
private fun containerFor(mode: StopwatchState): Color = when (mode) {
StopwatchState.RUNNING -> MaterialTheme.colorScheme.surfaceContainerHigh
StopwatchState.PAUSED, StopwatchState.IDLE -> MaterialTheme.colorScheme.surfaceContainer
}
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import de.jeanlucmakiola.clockula.domain.Lap
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.stopwatch.LapEmphasis
import de.jeanlucmakiola.clockula.domain.stopwatch.LapStats
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings
import kotlin.time.Duration
enum class LapKind { RECORDED, IN_PROGRESS }
data class LapRowState(
/** 1-based. The in-progress row's index is `recorded.size + 1`. */
val index: Int,
val kind: LapKind,
/** Never negative (D11). */
val split: Duration,
val cumulative: Duration,
/** Always NONE for [LapKind.IN_PROGRESS] (D7). */
val emphasis: LapEmphasis,
)
sealed interface StopwatchUiState {
/** Before the first repository emission. Not "idle". */
data object Loading : StopwatchUiState
data class Loaded(
/** The reading's mode, not the stored state (D6). */
val mode: StopwatchState,
val elapsed: Duration,
/** Newest first; empty when no lap has been recorded (D11). */
val laps: List<LapRowState>,
val canLap: Boolean,
) : StopwatchUiState
}
internal object StopwatchRows {
/**
* The recorded rows, newest first, with their best/worst emphasis. Pure, and
* derived from the lap table **alone** — nothing here moves with the clock,
* so the source computes it once per lap change rather than once per 60 Hz
* tick (a 999-row sort and a thousand allocations sixty times a second, for
* data that only changes when Lap is tapped).
*/
fun recorded(laps: List<Lap>): List<LapRowState> {
val stats = LapStats.of(laps)
return laps.sortedByDescending { it.index }.map { lap ->
LapRowState(
index = lap.index,
kind = LapKind.RECORDED,
split = lap.split,
cumulative = lap.cumulative,
emphasis = stats.emphasisFor(lap.index),
)
}
}
/** Pure; never throws. */
fun from(
run: StopwatchRun,
laps: List<Lap>,
elapsedRealtime: Duration,
): StopwatchUiState.Loaded = fromRecorded(run, recorded(laps), elapsedRealtime)
/**
* As [from], over rows [recorded] has already derived. This is the per-tick
* path: only the hero figure and the in-progress row can move. (A separate
* name rather than an overload: both signatures erase to the same `List`.)
*/
fun fromRecorded(
run: StopwatchRun,
recorded: List<LapRowState>,
elapsedRealtime: Duration,
): StopwatchUiState.Loaded {
val reading = StopwatchReadings.of(run, elapsedRealtime)
if (reading.mode == StopwatchState.IDLE) {
// An idle stopwatch shows no lap list, whatever the table still
// holds: the next start clears it anyway.
return StopwatchUiState.Loaded(
mode = StopwatchState.IDLE,
elapsed = Duration.ZERO,
laps = emptyList(),
canLap = false,
)
}
// The in-progress row only exists once a lap has been recorded: with
// none, its split *and* its total equal the hero figure, and the tab
// would print the same number three times (D11).
val laps = if (recorded.isEmpty()) {
emptyList()
} else {
buildList(recorded.size + 1) {
add(
LapRowState(
index = recorded.size + 1,
kind = LapKind.IN_PROGRESS,
split = reading.currentLapSplit,
cumulative = reading.elapsed,
emphasis = LapEmphasis.NONE,
),
)
addAll(recorded)
}
}
return StopwatchUiState.Loaded(
mode = reading.mode,
elapsed = reading.elapsed,
laps = laps,
canLap = reading.mode == StopwatchState.RUNNING && recorded.size < StopwatchLaps.MAX,
)
}
}
@@ -2,50 +2,91 @@ package de.jeanlucmakiola.clockula.ui.stopwatch
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.animateItemMotion
import kotlin.time.Duration
/**
* A top-level tab: its title bar and the readout, standing at zero. Starting,
* lapping and the readout's final typography are M7's — which is why the
* figure here is the same [ClockFormat] a running stopwatch will print.
* The Stopwatch tab: a fixed readout with its controls, over a lap list that
* runs **newest first** (D10). The interesting lap is the one that just
* happened, and a growing list must not push it off screen — which is Google
* Clock's order, the reference `PLAN.md` §11 names.
*
* Nothing on this screen is remembered: the run lives in DataStore and the laps
* in Room, so a recreation costs the tab nothing at all.
*/
@Composable
fun StopwatchScreen(modifier: Modifier = Modifier) {
CollapsingScaffold(title = stringResource(R.string.tab_stopwatch), modifier = modifier) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = GroupedListInset),
fun StopwatchScreen(
modifier: Modifier = Modifier,
viewModel: StopwatchViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val pillInset = LocalLivePillInset.current
val loaded = state as? StopwatchUiState.Loaded
CollapsingScaffold(
title = stringResource(R.string.tab_stopwatch),
modifier = modifier,
scrollable = false,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = ClockFormat.elapsed(Duration.ZERO),
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onSurface,
)
StopwatchReadoutPanel(
// Before the first emission there is nothing to show but a
// stopwatch standing at zero — which is what an idle one is.
mode = loaded?.mode ?: StopwatchState.IDLE,
elapsed = loaded?.elapsed ?: Duration.ZERO,
canLap = loaded?.canLap == true,
onStart = viewModel::onStart,
onPause = viewModel::onPause,
onLap = viewModel::onLap,
onReset = viewModel::onReset,
modifier = Modifier.padding(top = 8.dp),
)
val laps = loaded?.laps.orEmpty()
// No lap recorded ⇒ no list at all: the in-progress row's split and
// its total would both equal the hero figure (D11).
if (laps.isEmpty()) {
Spacer(Modifier.height(pillInset))
} else {
LapColumnHeader()
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(
bottom = StopwatchDefaults.ListBottomPadding + pillInset,
),
) {
itemsIndexed(laps, key = { _, row -> row.index }) { index, row ->
LapRow(
row = row,
position = positionOf(index, laps.size),
modifier = animateItemMotion(),
)
}
}
}
}
Spacer(Modifier.height(LocalLivePillInset.current))
}
}
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.Ticker
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/** The repository, the clock and the ticker into one state (D9). */
@Singleton
internal class StopwatchSource @Inject constructor(
private val stopwatch: StopwatchRepository,
private val elapsed: ElapsedRealtimeClock,
private val ticker: Ticker,
) {
/**
* Distinct-until-changed. Subscribes the ticker at
* [StopwatchDefaults.ReadoutTick] **only while the stored state is RUNNING**;
* otherwise exactly one emission (D9).
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun state(): Flow<StopwatchUiState.Loaded> =
combine(stopwatch.run(), stopwatch.laps(), ::Pair)
.flatMapLatest { (run, laps) ->
// The recorded rows and their best/worst emphasis depend on the
// lap table alone, so they are derived once here and not sixty
// times a second under the ticker.
val recorded = StopwatchRows.recorded(laps)
// A paused or idle stopwatch costs nothing: there is no ticker
// under it at all, only the one emission its record earned.
val ticks = if (run.state == StopwatchState.RUNNING) {
ticker.ticks(StopwatchDefaults.ReadoutTick)
} else {
flowOf(Unit)
}
// The clock is read once per emission and the arithmetic is the
// domain's: the screen re-derives none of it.
ticks.map { StopwatchRows.fromRecorded(run, recorded, elapsed.elapsedRealtime()) }
}
// A record that says running while the reading does not move — a
// stale anchor (D6) — collapses to one state rather than sixty a second.
.distinctUntilChanged()
}
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/** The tab's state and its four verbs; every verb goes through the engine (D2). */
@HiltViewModel
class StopwatchViewModel @Inject internal constructor(
private val engine: StopwatchEngine,
private val source: StopwatchSource,
) : ViewModel() {
/** `WhileSubscribed(5_000)`, seeded [StopwatchUiState.Loading]. */
val state: StateFlow<StopwatchUiState> = source.state()
// `WhileSubscribed`, not `Eagerly`: the upstream is a 60 Hz ticker, and
// eager sharing would keep it running with nobody looking. The timeout
// is what gives rotation its resilience, exactly as the Timers tab.
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), StopwatchUiState.Loading)
/** Start from idle, Resume from paused — one control, because the engine knows which (D13). */
fun onStart() = launchOnEngine { engine.start() }
fun onPause() = launchOnEngine { engine.pause() }
fun onLap() = launchOnEngine { engine.lap() }
fun onReset() = launchOnEngine { engine.reset() }
private fun launchOnEngine(block: suspend () -> Unit) {
viewModelScope.launch { block() }
}
private companion object {
/** The window a rotation has to re-subscribe in before the ticker is dropped. */
const val SUBSCRIPTION_GRACE_MS = 5_000L
}
}
+26
View File
@@ -164,4 +164,30 @@
<string name="ringtone_unavailable">Unavailable \u2014 the default alarm sound will be used</string>
<string name="ringtone_unknown">Unknown sound</string>
<string name="ringtone_access_may_lapse">Clockula may lose access to this file later</string>
<!-- M7: the stopwatch tab -->
<string name="stopwatch_start">Start</string>
<string name="stopwatch_pause">Pause</string>
<string name="stopwatch_resume">Resume</string>
<string name="stopwatch_reset">Reset</string>
<string name="stopwatch_lap">Lap</string>
<string name="stopwatch_lap_limit">Lap limit reached</string>
<string name="stopwatch_readout">Elapsed %1$s</string>
<string name="stopwatch_laps_header_lap">Lap</string>
<string name="stopwatch_laps_header_split">Split</string>
<string name="stopwatch_laps_header_total">Total</string>
<string name="stopwatch_lap_number">Lap %1$d</string>
<string name="stopwatch_lap_row">Lap %1$d, split %2$s, total %3$s</string>
<string name="stopwatch_lap_row_best">Lap %1$d, fastest, split %2$s, total %3$s</string>
<string name="stopwatch_lap_row_worst">Lap %1$d, slowest, split %2$s, total %3$s</string>
<string name="stopwatch_lap_row_in_progress">Lap %1$d in progress, split %2$s, total %3$s</string>
<!-- M7: the stopwatch notification. It makes no sound. -->
<string name="channel_stopwatch_name">Stopwatch</string>
<string name="channel_stopwatch_description">The running stopwatch and its controls. It makes no sound.</string>
<string name="stopwatch_notification_title">Stopwatch</string>
<string name="stopwatch_paused_at">Paused \u00b7 %1$s</string>
<plurals name="stopwatch_laps_recorded">
<item quantity="one">%1$d lap</item>
<item quantity="other">%1$d laps</item>
</plurals>
</resources>
@@ -0,0 +1,271 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Lap
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.stopwatch.LapEmphasis
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* §5.7 — 16 cases. The tab as pure data: the hero reading, the lap list newest
* first, and the in-progress row that only exists once a lap has been recorded
* (D10, D11).
*/
class StopwatchRowsTest {
private val now: Duration = 1_000.seconds
private fun running(
accumulated: Duration = Duration.ZERO,
anchor: Duration? = now,
lastLapCumulative: Duration = Duration.ZERO,
) = StopwatchRun(
state = StopwatchState.RUNNING,
accumulated = accumulated,
startedAtElapsedRealtime = anchor,
lastLapCumulative = lastLapCumulative,
)
private fun paused(
accumulated: Duration = 30.seconds,
lastLapCumulative: Duration = Duration.ZERO,
) = StopwatchRun(
state = StopwatchState.PAUSED,
accumulated = accumulated,
startedAtElapsedRealtime = null,
lastLapCumulative = lastLapCumulative,
)
private fun lapsOf(vararg splits: Long): List<Lap> {
var running = Duration.ZERO
return splits.map { it.seconds }.mapIndexed { position, split ->
running += split
Lap(index = position + 1, split = split, cumulative = running)
}
}
private fun rows(
run: StopwatchRun,
laps: List<Lap> = emptyList(),
elapsedRealtime: Duration = now,
) = StopwatchRows.from(run, laps, elapsedRealtime)
/** §5.7 #1 */
@Test
fun `an idle stopwatch is a zero readout with no list and no lap button`() {
val state = rows(StopwatchRun())
assertThat(state).isEqualTo(
StopwatchUiState.Loaded(
mode = StopwatchState.IDLE,
elapsed = Duration.ZERO,
laps = emptyList(),
canLap = false,
),
)
}
/** §5.7 #2 */
@Test
fun `a running stopwatch with no recorded lap shows no lap list at all`() {
val state = rows(running(accumulated = 30.seconds))
assertThat(state.laps).isEmpty()
}
/** §5.7 #3 */
@Test
fun `the first row is the lap in progress`() {
val state = rows(
running(accumulated = 30.seconds, lastLapCumulative = 12.seconds),
lapsOf(12),
)
assertThat(state.laps.first().index to state.laps.first().kind)
.isEqualTo(2 to LapKind.IN_PROGRESS)
}
/** §5.7 #4 */
@Test
fun `the lap list runs newest first`() {
val state = rows(
running(accumulated = 40.seconds, lastLapCumulative = 30.seconds),
lapsOf(10, 10, 10),
)
assertThat(state.laps.map { it.index }).containsExactly(4, 3, 2, 1).inOrder()
}
/** §5.7 #5 */
@Test
fun `a paused stopwatch keeps its in-progress row, frozen`() {
val state = rows(
paused(accumulated = 30.seconds, lastLapCumulative = 20.seconds),
lapsOf(10, 10),
elapsedRealtime = now + 5.seconds,
)
assertThat(state.laps.first()).isEqualTo(
LapRowState(
index = 3,
kind = LapKind.IN_PROGRESS,
split = 10.seconds,
cumulative = 30.seconds,
emphasis = LapEmphasis.NONE,
),
)
}
/** §5.7 #6 */
@Test
fun `the in-progress row splits from the last lap's total`() {
val state = rows(
running(accumulated = 30.seconds, lastLapCumulative = 12.seconds),
lapsOf(12),
)
assertThat(state.laps.first().cumulative to state.laps.first().split)
.isEqualTo(30.seconds to 18.seconds)
}
/** §5.7 #7 */
@Test
fun `a lap total above the reading floors the in-progress split at zero`() {
val state = rows(
running(accumulated = 10.seconds, lastLapCumulative = 40.seconds),
lapsOf(40),
)
assertThat(state.laps.first().split).isEqualTo(Duration.ZERO)
}
/** §5.7 #8 */
@Test
fun `three recorded laps mark one best and one worst, and never the one in progress`() {
val state = rows(
running(accumulated = 30.seconds, lastLapCumulative = 21.seconds),
lapsOf(10, 4, 7),
)
val byKind = state.laps.groupBy { it.kind }
assertThat(byKind.getValue(LapKind.RECORDED).map { it.emphasis })
.containsExactly(LapEmphasis.NONE, LapEmphasis.BEST, LapEmphasis.WORST)
assertThat(byKind.getValue(LapKind.IN_PROGRESS).map { it.emphasis })
.containsExactly(LapEmphasis.NONE)
}
/** §5.7 #9 */
@Test
fun `two recorded laps emphasise nothing`() {
val state = rows(
running(accumulated = 30.seconds, lastLapCumulative = 14.seconds),
lapsOf(10, 4),
)
assertThat(state.laps.map { it.emphasis }.toSet()).containsExactly(LapEmphasis.NONE)
}
/** §5.7 #10 */
@Test
fun `a running stopwatch below the cap can still lap`() {
val state = rows(running(accumulated = 30.seconds, lastLapCumulative = 10.seconds), lapsOf(10))
assertThat(state.canLap).isTrue()
}
/** §5.7 #11 */
@Test
fun `a running stopwatch at the cap cannot lap again`() {
val laps = (1..StopwatchLaps.MAX).map {
Lap(index = it, split = 1.seconds, cumulative = it.seconds)
}
val state = rows(
running(accumulated = 2_000.seconds, lastLapCumulative = StopwatchLaps.MAX.seconds),
laps,
)
assertThat(state.canLap).isFalse()
}
/** §5.7 #12 */
@Test
fun `a paused stopwatch has nothing to lap`() {
val state = rows(paused(lastLapCumulative = 10.seconds), lapsOf(10))
assertThat(state.canLap).isFalse()
}
/** §5.7 #13 */
@Test
fun `a run anchored in a previous boot is presented as paused at what it banked`() {
val stale = running(accumulated = 25.seconds, anchor = now + 500.seconds, lastLapCumulative = 10.seconds)
val state = rows(stale, lapsOf(10))
assertThat(Triple(state.mode, state.elapsed, state.canLap))
.isEqualTo(Triple(StopwatchState.PAUSED, 25.seconds, false))
}
/** §5.7 #14 */
@Test
fun `laps handed over in any order come back in descending index order`() {
val laps = lapsOf(10, 4, 7).shuffled()
val state = rows(running(accumulated = 30.seconds, lastLapCumulative = 21.seconds), laps)
val indices = state.laps.map { it.index }
assertThat(indices).isEqualTo(indices.sortedDescending())
assertThat(indices).containsExactly(4, 3, 2, 1)
}
/** §5.7 #15 */
@Test
fun `an idle stopwatch shows no lap list even when the table still holds one`() {
val state = rows(StopwatchRun(), lapsOf(10, 4, 7))
assertThat(state.laps).isEmpty()
}
/** §5.7 #16 */
@Test
fun `a second of monotonic clock moves only the reading and the lap in progress`() {
val run = running(accumulated = 30.seconds, lastLapCumulative = 21.seconds)
val laps = lapsOf(10, 4, 7)
val before = rows(run, laps)
val after = rows(run, laps, elapsedRealtime = now + 1.seconds)
assertThat(after.elapsed).isEqualTo(before.elapsed + 1.seconds)
assertThat(after.laps.filter { it.kind == LapKind.RECORDED })
.isEqualTo(before.laps.filter { it.kind == LapKind.RECORDED })
}
// --- M7 review finding 4: the per-tick path must equal the whole-model one ---
/**
* `StopwatchSource` derives the recorded rows once per lap change and re-uses
* them on every 60 Hz tick, so [StopwatchRows.fromRecorded] has to produce
* exactly what [StopwatchRows.from] does — including the idle case, where the
* rows are dropped whatever the table holds.
*/
@Test
fun `the per-tick path produces the same state as the whole model`() {
val laps = lapsOf(10, 4, 7)
val runs = listOf(
StopwatchRun(),
running(accumulated = 30.seconds, lastLapCumulative = 21.seconds),
paused(accumulated = 30.seconds, lastLapCumulative = 21.seconds),
running(accumulated = 30.seconds, anchor = now + 500.seconds, lastLapCumulative = 21.seconds),
)
runs.forEach { run ->
assertThat(StopwatchRows.fromRecorded(run, StopwatchRows.recorded(laps), now))
.isEqualTo(StopwatchRows.from(run, laps, now))
}
}
}
@@ -0,0 +1,232 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.StopwatchEngineHarness
import de.jeanlucmakiola.clockula.testing.stopwatchEngineHarness
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
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
import kotlin.time.Duration.Companion.seconds
/**
* §5.8 — 10 cases over the **real** [de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine],
* the real repository, a real DataStore under a `@TempDir` and the fake ticker.
* The four verbs go through the engine, not the repository, which is what keeps
* the foreground service moving with the state (D2) — the service handle's
* transitions are how a test proves the call took that route.
*/
class StopwatchViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
private fun TestScope.viewModel(harness: StopwatchEngineHarness) = StopwatchViewModel(
engine = harness.engine,
source = StopwatchSource(harness.stopwatch, harness.elapsed, harness.ticker),
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
private fun StopwatchUiState.loaded(): StopwatchUiState.Loaded = this as StopwatchUiState.Loaded
/** §5.8 #1 */
@Test
fun `the tab starts out loading, not idle`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
assertThat(viewModel.state.value).isEqualTo(StopwatchUiState.Loading)
advanceUntilIdle()
assertThat(viewModel.state.value).isInstanceOf(StopwatchUiState.Loaded::class.java)
}
/** §5.8 #2 */
@Test
fun `Start runs the stopwatch and brings the service up`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
assertThat(harness.storedRun().state).isEqualTo(StopwatchState.RUNNING)
assertThat(harness.service.active).isTrue()
}
/** §5.8 #3 */
@Test
fun `Pause banks the run and leaves the service up`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
viewModel.onPause()
advanceUntilIdle()
assertThat(harness.storedRun().state).isEqualTo(StopwatchState.PAUSED)
assertThat(harness.service.active).isTrue()
}
/** §5.8 #4 */
@Test
fun `Lap records a lap and the list grows by one`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
harness.elapsed.advance(10.seconds)
viewModel.onLap()
advanceUntilIdle()
val before = viewModel.state.value.loaded().laps.size
harness.elapsed.advance(10.seconds)
viewModel.onLap()
advanceUntilIdle()
assertThat(harness.storedLaps().map { it.index }).containsExactly(1, 2).inOrder()
assertThat(viewModel.state.value.loaded().laps).hasSize(before + 1)
}
/** §5.8 #5 */
@Test
fun `Reset empties the run, the laps and the shade`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
harness.elapsed.advance(10.seconds)
viewModel.onLap()
advanceUntilIdle()
viewModel.onReset()
advanceUntilIdle()
assertThat(harness.storedRun().state).isEqualTo(StopwatchState.IDLE)
assertThat(harness.storedLaps()).isEmpty()
assertThat(harness.service.active).isFalse()
}
/** §5.8 #6 */
@Test
fun `Lap at the cap records nothing and the button stays disabled`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
(1..StopwatchLaps.MAX).forEach { index ->
harness.lapDao.insert(
LapEntity(lapIndex = index, splitMillis = 1_000L, cumulativeMillis = index * 1_000L),
)
}
harness.elapsed.advance(2_000.seconds)
advanceUntilIdle()
viewModel.onLap()
advanceUntilIdle()
assertThat(harness.storedLaps()).hasSize(StopwatchLaps.MAX)
assertThat(viewModel.state.value.loaded().canLap).isFalse()
}
/** §5.8 #7 */
@Test
fun `Lap on a paused stopwatch records nothing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
viewModel.onPause()
advanceUntilIdle()
viewModel.onLap()
advanceUntilIdle()
assertThat(harness.storedLaps()).isEmpty()
}
/** §5.8 #8 — the checklist's "how the paused result is presented" (D26). */
@Test
fun `a run whose anchor a reboot invalidated is presented paused at the banked value`(
@TempDir tempDir: Path,
) = runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
harness.elapsed.advance(20.seconds)
viewModel.onLap()
advanceUntilIdle()
harness.elapsed.advance(20.seconds)
viewModel.onLap()
advanceUntilIdle()
harness.elapsed.reboot(uptime = 5.seconds)
harness.ticker.tick()
advanceUntilIdle()
val state = viewModel.state.value.loaded()
assertThat(state.mode to state.elapsed).isEqualTo(StopwatchState.PAUSED to 40.seconds)
assertThat(state.canLap).isFalse()
assertThat(state.laps.map { it.index to it.kind }).containsExactly(
3 to LapKind.IN_PROGRESS,
2 to LapKind.RECORDED,
1 to LapKind.RECORDED,
).inOrder()
assertThat(state.laps.first().split).isEqualTo(Duration.ZERO)
}
/** §5.8 #9 */
@Test
fun `a readout writes nothing`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
val writes = harness.dataStore.writes
val before = viewModel.state.value.loaded().elapsed
repeat(3) {
harness.elapsed.advance(1.seconds)
harness.ticker.tick()
advanceUntilIdle()
}
assertThat(viewModel.state.value.loaded().elapsed).isEqualTo(before + 3.seconds)
assertThat(harness.dataStore.writes).isEqualTo(writes)
}
/** §5.8 #10 */
@Test
fun `two Starts in one frame start one run`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
viewModel.onStart()
advanceUntilIdle()
assertThat(harness.storedRun().startedAtElapsedRealtime).isEqualTo(harness.elapsed.value)
assertThat(harness.service.transitions).containsExactly(true)
}
}