feat(stopwatch): the readout, the readings and what the shade is told

The pure-Kotlin half of the stopwatch, with no Android on it anywhere.

`StopwatchFormat` splits a duration into a major field it asks `ClockFormat`
for and two truncated hundredths, so the fraction can be drawn smaller than
the seconds beside it. `StopwatchReadings` turns a stored run plus the
monotonic clock into the one reading the tab, the pill and the notification
all draw — a run whose anchor belongs to a previous boot reads paused at what
it banked, never negative and never below the last lap's total. `LapStats`
marks the fastest and the slowest, but only once three laps exist, with ties
going to the earlier lap and an all-equal set marking neither.
`StopwatchNotification` says what the shade shows without knowing what a
`Notification` is.
This commit is contained in:
2026-09-22 10:23:25 +02:00
parent 6ea11f7bbd
commit 68728e5e7f
9 changed files with 805 additions and 0 deletions
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.clockula.domain.format
import java.util.Locale
import kotlin.time.Duration
/** The big readout, split so the hundredths can be drawn smaller than the seconds (D5). */
data class StopwatchReadout(
/** `M:SS` under an hour, `H:MM:SS` at or above it — exactly `ClockFormat.elapsed`. */
val major: String,
/** Exactly two ASCII digits, `00`..`99`. Truncated, never rounded. */
val hundredths: String,
) {
/** `"$major.$hundredths"`. */
val text: String get() = "$major.$hundredths"
}
object StopwatchFormat {
fun readout(elapsed: Duration): StopwatchReadout {
// The whole-second half is ClockFormat's, called and not reimplemented,
// so the tab, the pill and the timer row can never disagree about what
// a duration reads — and its truncate-never-round rule is inherited.
if (elapsed <= Duration.ZERO) return StopwatchReadout(ClockFormat.elapsed(Duration.ZERO), "00")
val hundredths = (elapsed.inWholeMilliseconds % 1_000L) / 10L
return StopwatchReadout(
major = ClockFormat.elapsed(elapsed),
// Locale.ROOT for ClockFormat's own reason: a locale with its own
// numerals would change the glyph width mid-count.
hundredths = String.format(Locale.ROOT, "%02d", hundredths),
)
}
/** [readout] as one string; the lap table's and the paused notification's form. */
fun precise(elapsed: Duration): String = readout(elapsed).text
}
@@ -0,0 +1,38 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
import de.jeanlucmakiola.clockula.domain.Lap
enum class LapEmphasis { NONE, BEST, WORST }
data class LapStats(val bestIndex: Int?, val worstIndex: Int?) {
fun emphasisFor(lapIndex: Int): LapEmphasis = when (lapIndex) {
bestIndex -> LapEmphasis.BEST
worstIndex -> LapEmphasis.WORST
else -> LapEmphasis.NONE
}
companion object {
/** Below three recorded laps there is no best and no worst (D7). */
const val MIN_LAPS: Int = 3
/** Pure; order-independent; ties go to the lowest index; all-equal gives neither. */
fun of(laps: List<Lap>): LapStats {
// With one lap there is nothing to compare; with two, "best" and
// "worst" mark every row and say the same thing twice.
if (laps.size < MIN_LAPS) return LapStats(bestIndex = null, worstIndex = null)
val fastest = laps.minOf { it.split }
val slowest = laps.maxOf { it.split }
// Every lap identical is not a run with a best and a worst in it.
if (fastest == slowest) return LapStats(bestIndex = null, worstIndex = null)
// Ties go to the earliest lap: "first to achieve it" is the
// defensible reading, and it is deterministic.
return LapStats(
bestIndex = laps.filter { it.split == fastest }.minOf { it.index },
worstIndex = laps.filter { it.split == slowest }.minOf { it.index },
)
}
}
}
@@ -0,0 +1,6 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
object StopwatchLaps {
/** The cap (D14). Three digits, so the index column has a fixed width. */
const val MAX: Int = 999
}
@@ -0,0 +1,50 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import kotlin.time.Duration
import kotlin.time.Instant
enum class StopwatchNotificationAction { LAP, PAUSE, RESUME, RESET }
data class StopwatchNotificationState(
val running: Boolean,
val elapsed: Duration,
/** `now - elapsed`. Non-null exactly while running. **Derived, never stored** (D4). */
val chronometerBase: Instant?,
/** Never negative. */
val lapCount: Int,
val actions: List<StopwatchNotificationAction>,
)
object StopwatchNotificationPolicy {
/** Null when the run is IDLE — nothing active, so the service stops itself. */
fun stateFor(
run: StopwatchRun,
lapCount: Int,
elapsedRealtime: Duration,
now: Instant,
): StopwatchNotificationState? {
if (!StopwatchReadings.isActive(run)) return null
val reading = StopwatchReadings.of(run, elapsedRealtime)
val running = reading.mode == StopwatchState.RUNNING
return StopwatchNotificationState(
running = running,
elapsed = reading.elapsed,
// The platform renders the ticking, so the service posts once per
// state change rather than sixty times a second. The base is derived
// here and never stored — the record carries no wall-clock field.
chronometerBase = (now - reading.elapsed).takeIf { running },
lapCount = lapCount.coerceAtLeast(0),
// The same pair the tab's controls offer for that mode — one
// vocabulary, two surfaces.
actions = if (running) {
listOf(StopwatchNotificationAction.LAP, StopwatchNotificationAction.PAUSE)
} else {
listOf(StopwatchNotificationAction.RESUME, StopwatchNotificationAction.RESET)
},
)
}
}
@@ -0,0 +1,58 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.snapshotAt
import kotlin.time.Duration
/** What the stopwatch reads *now* — from the snapshot, never from the stored row (D6). */
data class StopwatchReading(
/** IDLE, RUNNING or PAUSED. A stale RUNNING record reads PAUSED. */
val mode: StopwatchState,
/** Never negative. Zero when IDLE. */
val elapsed: Duration,
/** The in-progress lap's running split, floored at zero (D11). */
val currentLapSplit: Duration,
val anchorIsStale: Boolean,
)
object StopwatchReadings {
/** Pure; never throws. */
fun of(run: StopwatchRun, elapsedRealtime: Duration): StopwatchReading {
if (run.state == StopwatchState.IDLE) {
// An idle record carrying an accumulated value is corrupt; zero is
// what an idle stopwatch reads, whatever the bytes say.
return StopwatchReading(
mode = StopwatchState.IDLE,
elapsed = Duration.ZERO,
currentLapSplit = Duration.ZERO,
anchorIsStale = false,
)
}
val snapshot = run.snapshotAt(elapsedRealtime)
// Floored at the last lap's total as well as at zero. A lap recorded
// inside a segment the reboot repair later discarded keeps its own
// cumulative, and a readout that sits *below* a lap the same run printed
// is not a readout anyone can believe — the lap is a real measurement,
// so the run is at least that far along. The laps themselves are never
// deleted to make the arithmetic tidy (D11).
val elapsed = snapshot.elapsed
.coerceAtLeast(run.lastLapCumulative)
.coerceAtLeast(Duration.ZERO)
return StopwatchReading(
// The mode is the snapshot's, not the row's: a RUNNING record whose
// anchor did not survive a reboot reads PAUSED at what it banked.
mode = if (snapshot.isRunning) StopwatchState.RUNNING else StopwatchState.PAUSED,
elapsed = elapsed,
// Never negative: the floor above already guarantees it, and the
// clamp says so where a reader will look for it (D11).
currentLapSplit = (elapsed - run.lastLapCumulative).coerceAtLeast(Duration.ZERO),
anchorIsStale = snapshot.anchorIsStale,
)
}
/** The one "active" predicate: anything but IDLE (D15). */
fun isActive(run: StopwatchRun): Boolean = run.state != StopwatchState.IDLE
}