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
+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/` | 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/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)` |
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` |
| `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs` |
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider` |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `UiPrefs` |
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule` |
| `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 |
@@ -96,7 +98,9 @@ kit module — the kit's roadmap keeps schedulers app-local.
| `alarm/di/` | `AlarmModule` — `@Binds` for the four seams |
| `system/` | `RebootRepair` — the boot-id gate |
| `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
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:
everything that would have needed a shadow is behind one of the injected seams,
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
`MutableStateFlow<List<Entity>>`, so a write re-emits on the observing flow
exactly as Room's would. The three abstract DAOs are *extended*, not
@@ -605,10 +623,9 @@ locale metadata holder service.
| 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 |
| 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 |
| Stopwatch presentation, best/worst lap analysis (its post-reboot repair shipped in M3) | M7 |
| 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
`soundsAnyway` is true in all eight capability combinations — asserted
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.