docs: the app shell, and what it earned

ARCHITECTURE gains §12 for the shell and loses the two rows M4 paid off; the
permissions row now says what M4 actually asks for and leaves the rest to
M10's self-check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 13:07:53 +02:00
parent 0b31f7c9c4
commit d4120d8e06
2 changed files with 153 additions and 8 deletions
+24
View File
@@ -62,3 +62,27 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING
fallbacks ends in vibration, never in silence. fallbacks ends in vibration, never in silence.
- The post-reboot repair: a running timer no longer counts down from an anchor - The post-reboot repair: a running timer no longer counts down from an anchor
the reboot killed, and the stopwatch no longer invents a segment it never ran. the reboot killed, and the stopwatch no longer invents a segment it never ran.
- The app shell: four tabs — alarms, timers, stopwatch, world clock — under a
navigation bar that becomes a rail on a tablet, chosen by the window's size
rather than by hand. Back from a tab goes home to Alarms; back from Alarms
leaves the app, and the predictive-back gesture previews whichever of the two
is actually about to happen.
- The live pill: whatever is counting is visible from every tab, and pausable
and stoppable without going to find the row that owns it. It stays up while
paused — a control that vanishes under the thumb that pressed it is no control
— shows how many other timers are running behind it, and flips a timer that
reaches zero to "finished" the moment it does, rather than counting into
negative time. Stop resets; it never deletes a timer the user configured.
- The real ring screen, over the lock screen with the display kept on for
exactly as long as the window lives: the time, the label, snooze, and an
optional dismiss challenge — a two-term sum, or a two-second hold — that can
never strand anyone. Three wrong answers or sixty seconds of ringing, either
one, opens a way out; snooze is never gated; solving the challenge dismisses
on the spot instead of asking twice; and the hold completes from an
accessibility click, so a screen reader is not locked out of its own alarm.
Back cannot silence an alarm.
- The first Compose motion: fade-through between peer tabs, the kit's springs for
the pill coming and going and for the challenge block opening, all of which
already stand down when the system's "remove animations" setting is on.
- Notification permission is asked for once, on first launch, and the fact that
it was asked is recorded rather than guessed at.
+129 -8
View File
@@ -80,14 +80,16 @@ kit module — the kit's roadmap keeps schedulers app-local.
|---|---| |---|---|
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt` — plain Kotlin, no Android | | `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt` — plain Kotlin, no Android |
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the volume ramp, the ring policies | | `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the volume ramp, the ring policies |
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId` | | `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/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated |
| `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)` | | `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)` |
| `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`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs` | | `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `UiPrefs` |
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider` | | `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule` | | `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule` |
| `alarm/` | `AlarmEngine` and the four seams it talks to — `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` — plus `AlarmIntents` | | `alarm/` | `AlarmEngine` and the four seams it talks to — `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` — plus `AlarmIntents` |
| `alarm/android/` | the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed notification | | `alarm/android/` | the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed notification |
@@ -96,7 +98,9 @@ kit module — the kit's roadmap keeps schedulers app-local.
| `alarm/di/` | `AlarmModule` — `@Binds` for the four seams | | `alarm/di/` | `AlarmModule` — `@Binds` for the four seams |
| `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/ring/` | `AlarmRingActivity` — M3 ships its window flags and a placeholder; M4 ships the screen | | `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask |
| `ui/alarms/`, `ui/timers/`, `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M5–M8 fill them |
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
--- ---
@@ -482,11 +486,25 @@ half already runs on `@IoDispatcher`, wired once in `DataModule`.
## 8. Testing ## 8. Testing
JVM-first. 324 unit tests run in the gate; twelve instrumentation tests compile JVM-first. 474 unit tests run in the gate; twenty instrumentation tests compile
in it and run only on a device. There is **no Robolectric** and no plan for it: in it and run only on a device. There is **no Robolectric** and no plan for it:
everything that would have needed a shadow is behind one of the injected seams, everything that would have needed a shadow is behind one of the injected seams,
or is a pure function in `domain/alarm/`. or is a pure function in `domain/alarm/`.
That posture had to be *earned* again when the UI arrived, not merely kept. The
shell's whole decision surface was pushed out of the composables and into pure
Kotlin: `ShellNavigation` (what a tab tap does to the back stack, and where back
goes), `LivePillSelector` (which of several running things the pill is about),
`ClockFormat` (the readout) and `ChallengeGate` + `MathProblems` (the barrier in
front of a dismissal, and the promise that it always opens). What is left in the
composables is `Text(...)` over those values. The two ViewModels that need a main
dispatcher get one from a twenty-line JUnit 5 `MainDispatcherExtension`.
What genuinely needs hardware is written as the eight new instrumentation tests
and honestly labelled as such: a window really appearing over a lock screen with
`FLAG_KEEP_SCREEN_ON` set, a real system back press, a real activity recreation,
and a TalkBack-shaped accessibility click completing the hold challenge.
- **Fakes over `MutableStateFlow`.** The five fake DAOs are backed by a - **Fakes over `MutableStateFlow`.** The five fake DAOs are backed by a
`MutableStateFlow<List<Entity>>`, so a write re-emits on the observing flow `MutableStateFlow<List<Entity>>`, so a write re-emits on the observing flow
exactly as Room's would. The three abstract DAOs are *extended*, not exactly as Room's would. The three abstract DAOs are *extended*, not
@@ -605,10 +623,9 @@ locale metadata holder service.
| Not here | Milestone | | Not here | Milestone |
|---|---| |---|---|
| The ring screen itself — its layout, its motion, its dismiss challenge, its ViewModel. M3 ships the window flags and a plain placeholder | M4 |
| Any other UI, ViewModel or navigation beyond the theme | M4+ |
| Alarm list, edit surface, time picker, ringtone picker, repeat-day selector, per-alarm override UI | M5 | | Alarm list, edit surface, time picker, ringtone picker, repeat-day selector, per-alarm override UI | M5 |
| The runtime permission *requests* and the exact-alarm / full-screen-intent deep links. `AlarmCapabilities.snapshot()` exists; asking is M4's and explaining is M10's | M4 / 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 — the alarm list and editor (M5), creating and running a timer (M6), starting and lapping the stopwatch (M7), the zone list and analog face (M8). M4 ships the four title bars and their empty states | M5–M8 |
| Timers ringing — M6 reuses this milestone's audio path; M3 wires no timer to it | M6 | | Timers ringing — M6 reuses this milestone's audio path; M3 wires no timer to it | M6 |
| Stopwatch presentation, best/worst lap analysis (its post-reboot repair shipped in M3) | M7 | | 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 |
@@ -768,3 +785,107 @@ would edit the user's own alarm volume and leave it edited.
`RingPresentationPolicy` decides how the ring is *presented*, and its `RingPresentationPolicy` decides how the ring is *presented*, and its
`soundsAnyway` is true in all eight capability combinations — asserted `soundsAnyway` is true in all eight capability combinations — asserted
exhaustively, because that is the invariant the app lives on. exhaustively, because that is the invariant the app lives on.
---
## 12. The app shell
Four tabs, one host, and one surface that follows whatever is running.
### Navigation
`NavigationSuiteScaffold` from `material3-adaptive-navigation-suite`, taking its
default `navigationSuiteType`: the M3 Expressive **short navigation bar** on a
compact width and the **wide rail** on medium and expanded ones. Taking the
default rather than writing `if (compact) NavigationBar else NavigationRail` is
the point — the default also answers the tabletop posture and the compact-*height*
case a hand-rolled branch quietly gets wrong.
The `NavHost` is the single source of truth for the selected tab. There is no
`var selectedTab by rememberSaveable`: the selection is derived from
`currentBackStackEntryAsState()`, and Navigation Compose already saves its back
stack through a `SavedStateHandle`, so the tab survives rotation and process
death with no mirror of ours to drift.
The policy that back stack follows is a pure function, `ShellNavigation`:
- a tab tap is `popUpTo(start) { saveState = true }` + `launchSingleTop` +
`restoreState`; re-selecting the tab you are already on is the same command
with `restoreState = false`, which pops that tab's own inner stack back to its
root — the standard behaviour, and the contract M5's editor will rely on;
- back from any of the other three tabs returns to **Alarms**; back from Alarms,
from a null route and from any route the shell does not recognise leaves the
app — a nested destination's back belongs to the `NavController`, not to us.
Predictive back is the kit's `Modifier.predictiveBack`, gated on exactly that
policy: on Alarms the handler is *off*, so the gesture falls through to the
system and previews leaving the app, which is the truthful preview. Tab switches
use the kit's `fadeThrough()` — peer destinations have no spatial relationship,
and a slide would claim a hierarchy that is not there.
### The live pill
`PLAN.md` §9 asks for a running-state surface reachable from every tab. Its real
predicate is **active, not running**: pausing from the pill must not make the
pill vanish under the thumb that pressed it, or there is no way to resume or
stop from another tab — which is the flaw the pill exists to fix. So it shows for
`RUNNING`, `PAUSED` *and* `EXPIRED`, and its own Stop — which is `reset()`,
never `delete()` — is what removes it.
Precedence is total; first match wins:
| # | Subject |
|---|---|
| 1 | a timer that reads as **expired right now** (stored `EXPIRED`, or stored `RUNNING` whose snapshot has reached zero) |
| 2 | a timer that reads as **running**, the one with the smallest remaining |
| 3 | a timer that reads as **paused** |
| 4 | the **stopwatch**, when its state is not `IDLE` |
| 5 | otherwise no pill |
Ties inside a timer bucket break on `sortOrder` then `id` — the same "lower id
wins" rule the alarm engine's precedence already uses, so the app has one rule.
Timers outrank the stopwatch because a timer has a deadline: a missed timer costs
something, a missed stopwatch tick costs nothing.
The mode and value come from `Timer.snapshotAt` / `StopwatchRun.snapshotAt`, not
from the stored row — so §5's whole elapsed-realtime-versus-wall-clock story,
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
for M6's expiry service to write `markExpired`. The pill never counts into
negative time, and never reads `0:00` while claiming to run.
The pill is **not** a live region: at 1 Hz TalkBack would recite the countdown
for as long as it ran. The readout carries a full sentence as its content
description instead, read when focused and never announced unprompted.
### The third time-shaped seam
`Ticker` joins `WallClock`, `ElapsedRealtimeClock` and `ZoneProvider` in
`domain/time/`. A readout has to advance without a repository write, and the
discipline of §5 is that time is a *parameter*: so "re-read the clocks now"
became an injected flow rather than a `delay` at a call site. It emits once
immediately and then every period — one second for the pill, since the pill
formats to whole seconds — so a fresh subscriber is never blank. `RealTicker` is
nothing but `delay`, which makes it testable under `runTest`'s virtual clock.
### The dismiss challenge, and why it cannot strand anyone
`ChallengeGate` is a pure state machine whose single boolean `open` means
"dismissing is permitted now". `PLAN.md` §4's "must never be able to strand the
user" is made executable rather than promised: the escape hatch becomes
available on **either** three wrong answers **or** sixty seconds of ringing,
whichever comes first, for every challenge kind — asserted as a parameterised
invariant over `DismissChallenge`, not as three happy paths.
Three more guarantees hold alongside it. Snooze is never gated: the gate protects
dismissal only. Auto-silence still ends the ring at ten minutes regardless of the
gate, because that is the engine's, and the screen only follows the session to
`Finished`. And a wrong answer carries no lockout and no penalty timer — it
replaces the problem and that is all.
A completed hold *is* the dismissal, and a correct answer *is* the dismissal: no
"solve it, then press Dismiss again", because a half-awake person should not have
to discover a second step. The hold target also carries an accessibility
`onClick` that completes it outright — TalkBack cannot perform a press-and-hold,
and a challenge a screen reader cannot answer is precisely the stranding the
requirement forbids.