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