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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user