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,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>
|
||||
|
||||
Reference in New Issue
Block a user