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,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>