docs: the stopwatch, and why its reading is floored and banked
`ARCHITECTURE.md` gains §15 for the stopwatch and records the one correctness find this slice turned up: a lap taken inside a segment a reboot later discarded would have dragged the readout, the lap totals and the shade backwards. The reading is floored at the last lap's total — and the floor is banked, not merely displayed, or the shade's free-running chronometer walks away from a tab that is standing still. The package, module, receiver, service and permission tables all count one more; §10 loses the two rows M7 closed.
This commit is contained in:
@@ -138,3 +138,19 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING
|
|||||||
- Progress on each timer card is drawn by the Expressive wavy indicator, and the
|
- Progress on each timer card is drawn by the Expressive wavy indicator, and the
|
||||||
wave itself is the state — it flattens when you pause and when the timer is
|
wave itself is the state — it flattens when you pause and when the timer is
|
||||||
done.
|
done.
|
||||||
|
- The stopwatch tab, for real: start, pause and reset, with a readout down to
|
||||||
|
hundredths of a second that stays legible because the fraction is drawn
|
||||||
|
smaller than the seconds rather than shouting alongside them.
|
||||||
|
- Laps, each with its own split and its running total, newest at the top, and
|
||||||
|
the fastest and slowest of them marked — in colour and in words, so a screen
|
||||||
|
reader hears the distinction too. The lap you are in the middle of has its own
|
||||||
|
row, counting up.
|
||||||
|
- An ongoing notification with Lap and Pause in the shade, which costs no
|
||||||
|
battery to keep accurate because the system draws the ticking; Clockula posts
|
||||||
|
once when something actually changes.
|
||||||
|
- A stopwatch that keeps counting while the app is closed, and that comes back
|
||||||
|
after a restart paused — with every lap intact — at the time it had banked,
|
||||||
|
rather than inventing the time it lost. Changing the system clock, in either
|
||||||
|
direction, cannot move it at all.
|
||||||
|
- The app's big numbers now sit in fixed columns, so a readout no longer jitters
|
||||||
|
as its digits change — on every screen that shows one, not only the stopwatch.
|
||||||
|
|||||||
+257
-12
@@ -1,6 +1,6 @@
|
|||||||
# Clockula — architecture
|
# Clockula — architecture
|
||||||
|
|
||||||
How Clockula is built **today**, after M3. Where something does not exist yet,
|
How Clockula is built **today**, after M7. Where something does not exist yet,
|
||||||
this document says so and names the milestone that builds it, rather than
|
this document says so and names the milestone that builds it, rather than
|
||||||
describing a plan as though it were code. `PLAN.md` is the "why"; this is the
|
describing a plan as though it were code. `PLAN.md` is the "why"; this is the
|
||||||
"what, right now".
|
"what, right now".
|
||||||
@@ -81,15 +81,16 @@ kit module — the kit's roadmap keeps schedulers app-local.
|
|||||||
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent sentinel), `RepeatSummary.kt`, `AlarmDefaults.kt` — plain Kotlin, no Android |
|
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent sentinel), `RepeatSummary.kt`, `AlarmDefaults.kt` — plain Kotlin, no Android |
|
||||||
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 |
|
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 |
|
||||||
| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
|
| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
|
||||||
|
| `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` |
|
||||||
| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
|
| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
|
||||||
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId`, `Ticker` |
|
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId`, `Ticker` |
|
||||||
| `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function |
|
| `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function |
|
||||||
| `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — and `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data |
|
| `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat` — the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
|
||||||
| `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` |
|
| `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` |
|
||||||
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
|
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
|
||||||
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) |
|
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) |
|
||||||
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` |
|
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` |
|
||||||
| `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
|
| `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
|
||||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` |
|
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` |
|
||||||
| `data/ringtones/` | the two ringtone seams — `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer` — plus their `System*` implementations |
|
| `data/ringtones/` | the two ringtone seams — `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer` — plus their `System*` implementations |
|
||||||
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
|
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
|
||||||
@@ -105,13 +106,19 @@ kit module — the kit's roadmap keeps schedulers app-local.
|
|||||||
| `timer/receiver/` | `TimerExpiryReceiver` (the slot arriving), `TimerActionReceiver` (the notification's buttons) |
|
| `timer/receiver/` | `TimerExpiryReceiver` (the slot arriving), `TimerActionReceiver` (the notification's buttons) |
|
||||||
| `timer/service/` | `TimerService` — one foreground service for the countdown and the ring — and `TimerNotifications` |
|
| `timer/service/` | `TimerService` — one foreground service for the countdown and the ring — and `TimerNotifications` |
|
||||||
| `timer/di/` | `TimerModule` — `@Binds` for the three seams |
|
| `timer/di/` | `TimerModule` — `@Binds` for the three seams |
|
||||||
|
| `stopwatch/` | `StopwatchEngine` and the one seam it talks to — `StopwatchServiceHandle` — plus `StopwatchIntents` |
|
||||||
|
| `stopwatch/android/` | `ServiceStopwatchHandle`, the seam's Android implementation |
|
||||||
|
| `stopwatch/receiver/` | `StopwatchActionReceiver` (the notification's buttons; no extras, because there is one stopwatch) |
|
||||||
|
| `stopwatch/service/` | `StopwatchService` — the `specialUse` foreground service — and `StopwatchNotifications` |
|
||||||
|
| `stopwatch/di/` | `StopwatchModule` — `@Binds` for the one seam |
|
||||||
| `system/` | `RebootRepair` — the boot-id gate |
|
| `system/` | `RebootRepair` — the boot-id gate |
|
||||||
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
|
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
|
||||||
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask |
|
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask |
|
||||||
| `ui/common/` | `rememberAlarmTimeFormatter` — the one 12/24-hour formatter the list, the editor and the ring screen share — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need |
|
| `ui/common/` | `rememberAlarmTimeFormatter` — the one 12/24-hour formatter the list, the editor and the ring screen share — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need |
|
||||||
| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6) |
|
| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6) |
|
||||||
| `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor |
|
| `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor |
|
||||||
| `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M7–M8 fill them |
|
| `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` |
|
||||||
|
| `ui/worldclock/` | one top-level tab — a title bar and an empty state until M8 fills it |
|
||||||
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
|
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -292,6 +299,12 @@ broken. Losing the user's row silently would be worse.
|
|||||||
index and the split, and inserts — so two concurrent laps cannot collide on the
|
index and the split, and inserts — so two concurrent laps cannot collide on the
|
||||||
unique index.
|
unique index.
|
||||||
|
|
||||||
|
**M7 changes no schema here.** The table already had every column the tab
|
||||||
|
needs, so the stopwatch milestone adds no column, no version bump and no
|
||||||
|
migration; `LapDao` gains exactly one `@Query("SELECT COUNT(*) …")`, which the
|
||||||
|
engine's lap cap reads so appending a lap never materialises 999 rows. The
|
||||||
|
database stays at **version 2**.
|
||||||
|
|
||||||
### The repeat mask
|
### The repeat mask
|
||||||
|
|
||||||
Bit `n` is ISO day `n + 1`: **Monday is bit 0, Sunday bit 6**, so the conversion
|
Bit `n` is ISO day `n + 1`: **Monday is bit 0, Sunday bit 6**, so the conversion
|
||||||
@@ -480,6 +493,42 @@ knowingly-fabricated elapsed reading on screen for two more milestones was not
|
|||||||
defensible. **M3 owns the stopwatch's post-reboot repair; M7 owns its
|
defensible. **M3 owns the stopwatch's post-reboot repair; M7 owns its
|
||||||
presentation.**
|
presentation.**
|
||||||
|
|
||||||
|
**M7 closes the loop.** `RebootRepair`, `pauseAfterReboot` and `snapshotAt` are
|
||||||
|
untouched; what M7 adds is what the user then sees, and it is decided in one
|
||||||
|
pure place, `StopwatchReadings.of` (§15). The rule it carries is that **the mode
|
||||||
|
comes from the snapshot, not from the stored row**: a `RUNNING` record whose
|
||||||
|
anchor did not survive reads `PAUSED` at what it banked, so a stale run shows
|
||||||
|
Resume · Reset rather than a Pause button and a ticking chronometer, on all
|
||||||
|
three surfaces at once.
|
||||||
|
|
||||||
|
One honest consequence, recorded rather than papered over. A lap recorded inside
|
||||||
|
a segment the repair later discarded keeps its own cumulative, which can exceed
|
||||||
|
the run's banked `accumulated` — a stopwatch that ran forty seconds and lapped
|
||||||
|
twice, then met a reboot, has `accumulated = 0` and `lastLapCumulative = 40s`.
|
||||||
|
The reading is therefore **floored at the last lap's total** as well as at zero:
|
||||||
|
a readout sitting below a lap the same run printed is not one anyone can
|
||||||
|
believe, and the lap is a real measurement. The laps themselves are never
|
||||||
|
deleted to make the arithmetic tidy, and the in-progress row's split is clamped
|
||||||
|
at zero for the same reason. Reset clears all of it in one tap.
|
||||||
|
|
||||||
|
The floor is **banked, not only displayed**. Resuming such a run writes
|
||||||
|
`accumulated = max(accumulated, lastLapCumulative)`, and pausing and lapping
|
||||||
|
bank the same floored reading rather than the raw snapshot. Without that the
|
||||||
|
record would tick from below its own last lap: the tab would sit frozen at the
|
||||||
|
floored value for as long as the discarded segment lasted while the shade's
|
||||||
|
chronometer — derived from the floored reading once and then free-running —
|
||||||
|
counted on without it, and the next lap would record a cumulative *below* the
|
||||||
|
previous one and drag every readout backwards. `pauseAfterReboot` itself is
|
||||||
|
still untouched; the banking happens on the way back out, in
|
||||||
|
`StopwatchRepositoryImpl.start`, `pause` and `lap`.
|
||||||
|
|
||||||
|
For the same reason the engine's verbs guard on **the reading's** mode rather
|
||||||
|
than the stored row's, so they agree with the controls the user is being
|
||||||
|
offered: between a reboot and the repair, a record that still says `RUNNING`
|
||||||
|
reads `PAUSED` everywhere, and its Resume button resumes (banking first, as the
|
||||||
|
repair would have) instead of hitting a silent "already running" no-op, while
|
||||||
|
Lap is refused rather than recorded below the last one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Preferences
|
## 6. Preferences
|
||||||
@@ -491,7 +540,7 @@ Four slices:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs` — the family's shared key names |
|
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs` — the family's shared key names |
|
||||||
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `default_timer_duration_millis`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
|
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `default_timer_duration_millis`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
|
||||||
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` | `StopwatchPrefs`, behind `StopwatchStateStore` |
|
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` — the last of which M7 is the first caller of, for the in-progress lap's split | `StopwatchPrefs`, behind `StopwatchStateStore` |
|
||||||
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore` — the persisted half of the reboot gate |
|
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore` — the persisted half of the reboot gate |
|
||||||
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs` — the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
|
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs` — the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
|
||||||
|
|
||||||
@@ -541,7 +590,7 @@ before the first frame, and the alternative is an app only "clear data" can fix.
|
|||||||
|
|
||||||
## 7. Dependency injection
|
## 7. Dependency injection
|
||||||
|
|
||||||
Hilt, `SingletonComponent` throughout. Seven app modules plus the kit's:
|
Hilt, `SingletonComponent` throughout. Eight app modules plus the kit's:
|
||||||
|
|
||||||
| Module | Provides |
|
| Module | Provides |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -551,6 +600,7 @@ Hilt, `SingletonComponent` throughout. Seven app modules plus the kit's:
|
|||||||
| `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` |
|
| `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` |
|
||||||
| `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` |
|
| `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` |
|
||||||
| `RingtoneModule` | `@Binds` for the two ringtone seams: `RingtoneCatalog`, `RingtonePreviewer` |
|
| `RingtoneModule` | `@Binds` for the two ringtone seams: `RingtoneCatalog`, `RingtonePreviewer` |
|
||||||
|
| `StopwatchModule` | `@Binds` for the stopwatch engine's one seam: `StopwatchServiceHandle` (the foreground service). It has no scheduler, because nothing the stopwatch does has a deadline |
|
||||||
| `TimerModule` | `@Binds` for the timer engine's three seams: `TimerScheduler` (the one elapsed-realtime slot), `TimerServiceHandle` (the foreground service), `AlarmRingStatus` (read-only: "is an alarm ringing") |
|
| `TimerModule` | `@Binds` for the timer engine's three seams: `TimerScheduler` (the one elapsed-realtime slot), `TimerServiceHandle` (the foreground service), `AlarmRingStatus` (read-only: "is an alarm ringing") |
|
||||||
|
|
||||||
From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the
|
From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the
|
||||||
@@ -795,10 +845,9 @@ AppCompat's locale metadata holder service.
|
|||||||
| Not here | Milestone |
|
| Not here | Milestone |
|
||||||
|---|---|
|
|---|---|
|
||||||
| The exact-alarm and full-screen-intent grant deep links, and any explanation of a denial. M4 asks for `POST_NOTIFICATIONS` once on first launch; the rest waits for the self-check screen, because a bare jump into system settings with no reason given is hostile | M10 |
|
| The exact-alarm and full-screen-intent grant deep links, and any explanation of a denial. M4 asks for `POST_NOTIFICATIONS` once on first launch; the rest waits for the self-check screen, because a bare jump into system settings with no reason given is hostile | M10 |
|
||||||
| Every tab's real content beyond Alarms and Timers — starting and lapping the stopwatch (M7), the zone list and analog face (M8). Those two keep M4's title bars and empty states | M7–M8 |
|
| Every tab's real content beyond Alarms, Timers and the Stopwatch — the zone list and the analog face. The world clock keeps M4's title bar and empty state | M8 |
|
||||||
| Reordering alarms by hand. The list is ordered by time of day (§13) and `alarms` has no `sort_order` column, so this would be a schema change for a preference the time order already answers | not planned for v1 |
|
| Reordering alarms by hand. The list is ordered by time of day (§13) and `alarms` has no `sort_order` column, so this would be a schema change for a preference the time order already answers | not planned for v1 |
|
||||||
| A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; this one is still global and still `AlarmRing.AUTO_SILENCE_AFTER` | M10 at the earliest |
|
| A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; this one is still global and still `AlarmRing.AUTO_SILENCE_AFTER` | M10 at the earliest |
|
||||||
| Stopwatch presentation, best/worst lap analysis (its post-reboot repair shipped in M3) | M7 |
|
|
||||||
| ICU city and zone display names, offsets, day differences | M8 |
|
| ICU city and zone display names, offsets, day differences | M8 |
|
||||||
| The `android.provider.AlarmClock` intent surface and its hostile-extra validation (`TimeOfDay.clamped` and `Zones.normalise` exist so M9 has something to call) | M9 |
|
| The `android.provider.AlarmClock` intent surface and its hostile-extra validation (`TimeOfDay.clamped` and `Zones.normalise` exist so M9 has something to call) | M9 |
|
||||||
| The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 |
|
| The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 |
|
||||||
@@ -809,6 +858,13 @@ AppCompat's locale metadata holder service.
|
|||||||
| A presets management screen, and editing `default_timer_duration_millis` or `default_timer_ringtone_uri`. M6 reads both and offers the two-gesture preset surface on the setup panel | M10 |
|
| A presets management screen, and editing `default_timer_duration_millis` or `default_timer_ringtone_uri`. M6 reads both and offers the two-gesture preset surface on the setup panel | M10 |
|
||||||
| A timer that ramps its volume, a dismiss challenge or a snooze for a timer | not planned for v1 |
|
| A timer that ramps its volume, a dismiss challenge or a snooze for a timer | not planned for v1 |
|
||||||
| Surfacing `TimerSnapshot.anchorIsStale` in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 | not planned for v1 |
|
| Surfacing `TimerSnapshot.anchorIsStale` in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 | not planned for v1 |
|
||||||
|
| Lap export, sharing, deletion or editing, and any "previous run" history. A run's laps belong to the run that produced them; keeping old runs is a different feature with its own table | not planned for v1 |
|
||||||
|
| A Lap button on the live pill. The pill has two controls by design, and a third would rewrite a contract M4 asserted | not planned for v1 |
|
||||||
|
| A user-configurable lap cap or readout precision. Both are constants with reasons — 999 laps, hundredths — and M10's settings screen is not asked to carry them | not planned for v1 |
|
||||||
|
| Reordering, grouping or filtering laps. The list is index order, newest first, and that is the whole of it | not planned for v1 |
|
||||||
|
| A countdown-to-start, split-lap alarms, or any sound or vibration from the stopwatch. It is silent, and a build rule says so (§8) | not planned for v1 |
|
||||||
|
| An analog stopwatch face or a `MaterialShapes` showpiece on the Stopwatch tab. `PLAN.md` §8 sanctions the showpiece for the analog world-clock face, the ring screen and timer progress; a stopwatch has no progress to be a fraction of | not planned for v1 |
|
||||||
|
| Surfacing `StopwatchReading.anchorIsStale` in the UI, for the same reason the timers' is not: the repair makes it momentary, and the value displayed is already honest | not planned for v1 |
|
||||||
| Screenshots and store listing polish | M11 |
|
| Screenshots and store listing polish | M11 |
|
||||||
|
|
||||||
Also absent by design: any seeded default data — no starter alarm and no home
|
Also absent by design: any seeded default data — no starter alarm and no home
|
||||||
@@ -1063,11 +1119,18 @@ from `TimerMode` in one total `when`.
|
|||||||
registration and a foreground service that have to move with the state, so
|
registration and a foreground service that have to move with the state, so
|
||||||
pausing from the pill would otherwise leave the expiry slot pointing at a dead
|
pausing from the pill would otherwise leave the expiry slot pointing at a dead
|
||||||
deadline and the service posting a stale notification. Self-healing — the next
|
deadline and the service posting a stale notification. Self-healing — the next
|
||||||
sweep would find nothing due — but wasteful and wrong. The stopwatch branches
|
sweep would find nothing due — but wasteful and wrong.
|
||||||
keep calling `StopwatchRepository` until M7.
|
|
||||||
|
|
||||||
The mode and value come from `Timer.snapshotAt` / `StopwatchRun.snapshotAt`, not
|
**M7 did the same for the stopwatch branches**, for the same reason: after M7
|
||||||
from the stored row — so §5's whole elapsed-realtime-versus-wall-clock story,
|
there is a foreground service that has to move with the state, so pausing from
|
||||||
|
the pill would otherwise leave the shade showing a running stopwatch, and Stop
|
||||||
|
would leave the service up with nothing to show. The pill's own contract is
|
||||||
|
unchanged — it shows for RUNNING and PAUSED, never IDLE; Stop is `reset()` and
|
||||||
|
never `delete()`; and it gets **no Lap button**, because the pill has two
|
||||||
|
controls by design.
|
||||||
|
|
||||||
|
The mode and value come from `TimerReadings` / `StopwatchReadings`, both of them
|
||||||
|
over the stored row's *snapshot* rather than over the row — so §5's whole elapsed-realtime-versus-wall-clock story,
|
||||||
stale anchor and all, is honoured once. The consequence that matters: a running
|
stale anchor and all, is honoured once. The consequence that matters: a running
|
||||||
timer that reaches zero flips the pill to `EXPIRED` immediately, without waiting
|
timer that reaches zero flips the pill to `EXPIRED` immediately, without waiting
|
||||||
for the expiry sweep to write `markExpired`. The pill never counts into
|
for the expiry sweep to write `markExpired`. The pill never counts into
|
||||||
@@ -1584,3 +1647,185 @@ live pill already uses for an expired timer, so the two surfaces agree. The
|
|||||||
readout is `headlineLarge` and the setup panel's entry is `displayMedium` —
|
readout is `headlineLarge` and the setup panel's entry is `displayMedium` —
|
||||||
plain scale roles, because `ui/theme/Type.kt` says the big-readout ramp gets
|
plain scale roles, because `ui/theme/Type.kt` says the big-readout ramp gets
|
||||||
settled against a working stopwatch in M7, not guessed at in the scaffolding.
|
settled against a working stopwatch in M7, not guessed at in the scaffolding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. The stopwatch
|
||||||
|
|
||||||
|
Read this before changing anything stopwatch-shaped.
|
||||||
|
|
||||||
|
### The shape
|
||||||
|
|
||||||
|
`StopwatchEngine` is the third engine and a peer of the other two: plain Kotlin,
|
||||||
|
**no `android.*` import ever** (a build rule), reaching the platform through
|
||||||
|
**one** seam it does not implement, `StopwatchServiceHandle`. Every public entry
|
||||||
|
point is serialised on one non-reentrant `Mutex`, because they arrive from
|
||||||
|
threads that know nothing about each other — a notification-button broadcast,
|
||||||
|
the service's own collector, the tab's ViewModel, the shell's pill,
|
||||||
|
`ClockulaApp`'s launch pass.
|
||||||
|
|
||||||
|
It is deliberately much smaller than `TimerEngine`, because the stopwatch has
|
||||||
|
less to own: **no AlarmManager registration at all** (nothing has a deadline),
|
||||||
|
**no ring** (nothing expires), **no arbitration** (it makes no sound), and one
|
||||||
|
record rather than N rows. What it does have is four verbs — `start`, `pause`,
|
||||||
|
`lap`, `reset` — reachable from the shade with no ViewModel in sight, which is
|
||||||
|
why they live on the engine and not on a screen. `StopwatchRepository` stays the
|
||||||
|
storage face and gained exactly one method, `lapCount()`; nothing above the
|
||||||
|
engine calls its lifecycle verbs any more, including `ShellViewModel` (§12).
|
||||||
|
|
||||||
|
Like the other two it is not driven by a Flow: `resync()` is called explicitly.
|
||||||
|
|
||||||
|
### The service type, and why not `systemExempted`
|
||||||
|
|
||||||
|
`specialUse`, with a `stopwatch` subtype, and one new permission. The reasoning
|
||||||
|
is in §9 and is the decision in this milestone worth looking at hardest: the
|
||||||
|
easy answer would have the app tell the platform it is continuing alarm
|
||||||
|
functionality, which it is not.
|
||||||
|
|
||||||
|
The service is up while the run is `RUNNING` **or** `PAUSED`, and down when it
|
||||||
|
is `IDLE` — exactly the live pill's predicate and exactly
|
||||||
|
`StopwatchReadings.isActive`, so the shade, the pill and the tab cannot disagree
|
||||||
|
about whether there is a stopwatch. `PAUSED` is in it so a user who paused from
|
||||||
|
the shade still has a Resume there.
|
||||||
|
|
||||||
|
One consequence, accepted: after a reboot the repair leaves a running stopwatch
|
||||||
|
paused, so the service comes back up with a paused notification nobody asked
|
||||||
|
for. It is truthful — the stopwatch *is* paused and its laps are intact — and
|
||||||
|
its own Reset clears it in one tap. Resetting automatically would destroy laps
|
||||||
|
the user recorded, which is worse.
|
||||||
|
|
||||||
|
### The chronometer base is derived and never stored
|
||||||
|
|
||||||
|
The running notification is the **platform chronometer counting up**:
|
||||||
|
`setUsesChronometer(true)` + `setChronometerCountDown(false)` + `setWhen(base)`,
|
||||||
|
so the system renders the ticking and the service posts once per state change
|
||||||
|
rather than sixty times a second. The base is `wallClock.now() - elapsed`,
|
||||||
|
computed at post time and **never written anywhere** — which is how the
|
||||||
|
notification gets a wall-clock base without breaking §5's "`StopwatchRun`
|
||||||
|
carries no wall-clock field whatsoever", a rule a test asserts on the stored
|
||||||
|
bytes. The timer's equivalent base *is* a stored column and therefore needs
|
||||||
|
re-anchoring on a clock change; the stopwatch's needs nothing but a re-post,
|
||||||
|
which is all `onSystemTimeChanged()` does.
|
||||||
|
|
||||||
|
The shade's readout is therefore whole seconds. A *paused* body is frozen, so it
|
||||||
|
shows the full `StopwatchFormat.precise` value — it is stable, and the precision
|
||||||
|
is the point of having stopped. The channel is its own, `stopwatch`, at
|
||||||
|
`IMPORTANCE_LOW` with sound and vibration off: nothing here ever needs to
|
||||||
|
heads-up, because nothing here ever finishes. That is why there is no
|
||||||
|
alert-once rule, no `alreadyAlerted` flag and no session window anywhere in this
|
||||||
|
path. Notification id 1_003; actions are broadcasts into
|
||||||
|
`StopwatchActionReceiver` carrying **no extras at all**, because there is
|
||||||
|
exactly one stopwatch.
|
||||||
|
|
||||||
|
### The readout is hundredths, and it is two strings
|
||||||
|
|
||||||
|
`StopwatchFormat.readout` returns a `StopwatchReadout(major, hundredths)`, not
|
||||||
|
one string: the tab draws `major` at the hero scale and `hundredths` two ranks
|
||||||
|
down in `onSurfaceVariant`, baseline-aligned. A readout whose hundredths are the
|
||||||
|
same size as its seconds makes the fastest-changing glyphs the loudest ones and
|
||||||
|
drags the eye off the number that matters.
|
||||||
|
|
||||||
|
`major` **is** `ClockFormat.elapsed`, called rather than reimplemented, so the
|
||||||
|
stopwatch, the live pill and the timer row can never disagree about what a
|
||||||
|
duration reads, and the truncate-never-round rule is inherited rather than
|
||||||
|
restated. `hundredths` is `(millis % 1000) / 10`, zero-padded through
|
||||||
|
`Locale.ROOT` for the same glyph-column reason. Truncated, never rounded: a
|
||||||
|
stopwatch must never read ahead of reality. Hundredths and not tenths (too
|
||||||
|
coarse to time anything by hand) and not milliseconds (three digits that are
|
||||||
|
noise at 60 Hz).
|
||||||
|
|
||||||
|
### `StopwatchReadings`: one reading, three callers
|
||||||
|
|
||||||
|
`StopwatchReadings.of(run, elapsedRealtime)` is the single pure reading, and
|
||||||
|
`LivePillSelector`, `StopwatchNotificationPolicy` and `StopwatchRows` all call
|
||||||
|
it — the same extraction M6 made for `TimerReadings`, and for the same reason:
|
||||||
|
three copies of "what does this read right now" is how three surfaces start
|
||||||
|
disagreeing. `LivePillSelectorTest` passing **unmodified** is the proof the
|
||||||
|
extraction changed no behaviour. Its two rules are in §5: the mode comes from
|
||||||
|
the snapshot, and the value is floored at the last lap's total.
|
||||||
|
|
||||||
|
### Best and worst
|
||||||
|
|
||||||
|
Decided in `LapStats`, because the roadmap left them open:
|
||||||
|
|
||||||
|
- **Fewer than three recorded laps ⇒ no emphasis at all** (`LapStats.MIN_LAPS`,
|
||||||
|
asserted as a constant so a change is deliberate). With one lap there is
|
||||||
|
nothing to compare; with two, "best" and "worst" mark every row and say the
|
||||||
|
same thing twice.
|
||||||
|
- **Ties go to the lowest lap index** — "first to achieve it" is the defensible
|
||||||
|
reading and it is deterministic.
|
||||||
|
- **If the fastest and the slowest split are equal, neither exists.** Every lap
|
||||||
|
identical is not a run with a best and a worst in it.
|
||||||
|
- **The in-progress lap is never emphasised** and is never in `LapStats`' input:
|
||||||
|
it has not finished, so it cannot be the fastest. A lap can therefore never be
|
||||||
|
both, which falls out of the rules rather than needing a guard.
|
||||||
|
|
||||||
|
Best is `colorScheme.primary` and worst is `colorScheme.tertiary` — both scheme
|
||||||
|
tokens, and `error` is deliberately not used, because a slow lap is not a
|
||||||
|
failure. Colour is not the only channel: each row carries a content description
|
||||||
|
naming what it is, since a distinction a screen reader cannot hear is not a
|
||||||
|
distinction.
|
||||||
|
|
||||||
|
### The tab
|
||||||
|
|
||||||
|
A fixed readout and its controls **above** a lap list — scrolling a long list
|
||||||
|
must not put Lap out of reach mid-run — and the list runs **newest first**,
|
||||||
|
which is Google Clock's order (`PLAN.md` §11). A lap row is 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.
|
||||||
|
|
||||||
|
The first row is the **lap in progress**: index `laps.size + 1`, cumulative the
|
||||||
|
current reading, split `elapsed - lastLapCumulative` floored at zero — which is
|
||||||
|
what `stopwatch_last_lap_cumulative_millis` has existed for since M2 with no
|
||||||
|
caller. It appears **only once at least one lap has been recorded**: with none,
|
||||||
|
its split *and* its cumulative equal the hero figure, so the tab would print the
|
||||||
|
same number three times. It is shown while paused too, frozen, because the user
|
||||||
|
stopped mid-lap and that is true.
|
||||||
|
|
||||||
|
The vocabulary is **Start · Pause/Lap · Resume/Reset**. 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 again; the pill's Stop still resets from any state, which is M4's
|
||||||
|
contract and unchanged. The word "Stop" is deliberately absent from this tab: in
|
||||||
|
Clockula "Stop" already means "reset", and a Stop that paused would be the one
|
||||||
|
inconsistency in the app's verbs.
|
||||||
|
|
||||||
|
The cadence is `StopwatchDefaults.ReadoutTick` — 16 ms, about 60 Hz, the
|
||||||
|
display's own cadence — and the ticker is subscribed **only while the stored
|
||||||
|
state is RUNNING**. A paused or idle stopwatch therefore costs nothing, and the
|
||||||
|
flow is distinct-until-changed, which absorbs the one case where the record says
|
||||||
|
running and the reading does not move. The cadence is a `Ticker` parameter, never
|
||||||
|
a `delay` at a call site.
|
||||||
|
|
||||||
|
`StopwatchLaps.MAX = 999`. Beyond it the Lap button is disabled (carrying "Lap
|
||||||
|
limit reached" as its description) and `StopwatchEngine.lap()` returns null. An
|
||||||
|
unbounded table behind a button that can be held down is a storage leak the user
|
||||||
|
cannot see or clear except by resetting — and 999 keeps the index column three
|
||||||
|
digits wide, which is a layout fact as well as a limit. The guard is the
|
||||||
|
engine's, under its lock, over `lapCount()`.
|
||||||
|
|
||||||
|
Deliberately no `MaterialShapes` morph and no wavy progress: a stopwatch has no
|
||||||
|
progress — there is nothing to be a fraction of — and a decorative arc would be
|
||||||
|
a lie drawn to two decimal places.
|
||||||
|
|
||||||
|
### The typography, settled here for the whole app
|
||||||
|
|
||||||
|
`ui/theme/Type.kt` carried this as a written promise from M0, and M7 settles it
|
||||||
|
as two things.
|
||||||
|
|
||||||
|
`ClockulaTypography` applies **tabular figures** (`fontFeatureSettings = "tnum"`)
|
||||||
|
to the display, headline and title roles of the M3 default. That is the app-wide
|
||||||
|
half: every existing readout — `TimerRow`'s `headlineLarge`, `TimerSetupPanel`'s
|
||||||
|
`displayMedium`, the live pill's `titleMedium` — stops jittering as its digits
|
||||||
|
change, with **no call-site change anywhere**. Body and label roles keep
|
||||||
|
proportional figures, because prose is not a column of numbers.
|
||||||
|
|
||||||
|
`ClockulaReadoutDefaults` names the three roles a readout needs, in the M3
|
||||||
|
`*Defaults` shape (composable getters over `MaterialTheme.typography`, so a
|
||||||
|
theme change still flows): `Hero` (`displayLarge`, `FontWeight.Medium`),
|
||||||
|
`HeroFraction` (`headlineMedium`, about half the hero's size) and `Figure`
|
||||||
|
(`bodyLarge` with tabular figures — a figure inside a table row, the one place
|
||||||
|
body-scale text *is* a column of numbers). No new font, no size outside the M3
|
||||||
|
scale, and no weight above Medium: `PLAN.md` §8's "Expressive ≠ big or bold"
|
||||||
|
applies hardest to the one screen most tempted to break it. A build rule keeps
|
||||||
|
`fontFeatureSettings` under `ui/theme/` so it is configured once.
|
||||||
|
|||||||
Reference in New Issue
Block a user