From 36f6b356d0c993e77a37e975b7dfa0a260dfa26e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 22 Sep 2026 13:05:24 +0200 Subject: [PATCH] docs: the world clock, its seam and its caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ARCHITECTURE.md` gains §16 for the tab — where the zone list comes from, why ICU sits behind a seam, what `ZoneDirectory` caches and on what key, and why every comparison is read at the instant rather than against a stored offset. The module, package and rule tables count the new arrivals. --- CHANGELOG.md | 14 ++ docs/ARCHITECTURE.md | 325 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 326 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f951629..59b584b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,3 +154,17 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING 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. +- A world clock tab, with an analog face for your own time zone whose dial + changes shape with the hour — so a glance says whether it is the middle of the + night there, without a line of text saying so. +- Cities added from the device's own time-zone database, searched by name in + your language — accents and all, so typing "sao" finds São Paulo — with the + country and the offset from GMT shown before you pick. +- Each city shows its time, how far ahead or behind your own it is, and whether + it is a different day; all of it re-read from the real time-zone rules, so a + daylight-saving change in either place is right on the day it happens. +- Cities reordered by dragging them, or by a "move up" / "move down" action a + screen reader can reach — a list you can only rearrange by dragging is a list + some people cannot rearrange at all. +- A home time zone you can set by hand or leave following the device, and which + every city's offset and day difference are measured against. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e4eceaf..e7ebaeb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Clockula — architecture -How Clockula is built **today**, after M7. Where something does not exist yet, +How Clockula is built **today**, after M8. Where something does not exist yet, 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 "what, right now". @@ -48,12 +48,19 @@ through. │ entities │ typed prefs ┌──────────────┴─────────────┐ ┌─────────┴───────────────┐ │ DAOs + mappers (Room) │ │ PrefStore (DataStore) │ - │ ClockulaDatabase v1 │ │ clockula_prefs │ + │ ClockulaDatabase v2 │ │ clockula_prefs │ └──────────────┬─────────────┘ └─────────┬───────────────┘ │ │ SQLite preferences_pb ``` +One seam sits **beside** the repositories rather than under them: +`data/zones/`'s `ZoneNames` and `ZoneDirectory` (M8). They read the *platform* — +the device's tzdata through `ZoneProvider.available()` and ICU's localised names +through `android.icu` — not storage, so they have no entity, no DAO and no +migration. They live in `data/` for the same reason `data/ringtones/` does: the +thing behind them is an Android API the rest of the app must not name. + The seam is the repository interface list. Above it there is no `AlarmEntity`, no `@Query`, no `androidx.room` import and no `ClockulaDatabase` reference — `ArchitectureRulesTest` greps the whole main source set for exactly those names @@ -83,18 +90,20 @@ kit module — the kit's roadmap keeps schedulers app-local. | `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/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId`, `Ticker` | +| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()` — the device's tzdata), `BootId`, `Ticker` | +| `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone`, `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) | | `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 — `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/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/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` | +| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` — unchanged since M2; M8 is the milestone that finally calls all of it | +| `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory` — the locale-keyed catalog cache and the DST-keyed display-name memo | | `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` | | `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/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` | -| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule` | +| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8 — one `@Binds`, `ZoneNames → IcuZoneNames`) | | `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/receiver/` | `AlarmFireReceiver`, `AlarmActionReceiver`, `SystemEventReceiver` | @@ -113,12 +122,12 @@ kit module — the kit's roadmap keeps schedulers app-local. | `stopwatch/di/` | `StopwatchModule` — `@Binds` for the one seam | | `system/` | `RebootRepair` — the boot-id gate | | `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/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/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is **gone**: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller | +| `ui/common/` | `rememberLocalTimeFormatter` — the app's **one** 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17) — 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/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/` | 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/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` | | `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls | --- @@ -287,6 +296,18 @@ city table (`PLAN.md` §5). A zone the device's tzdata later drops is **kept**, not blanked; `WorldClock.isKnownZone` reports it and the UI can show it as broken. Losing the user's row silently would be worse. +**M8 changes no schema here.** M2 built exactly the surface the world clock +needs, so the milestone that finally uses this table adds no column, no version +bump and no migration, and `WorldClockEntity`, `WorldClockDao`, +`WorldClockMapper` and `WorldClockRepository(+Impl)` are not edited at all. What +it does is give all three of `label`, `sort_order` and the unique `zone_id` +their **first caller**: a row shows the user's `label` when it has one and the +ICU city otherwise, `sort_order` carries the hand reorder (`reorder`'s first +caller, from both a drag and the accessibility actions), and `zone_id`'s +uniqueness is what lets the picker leave an already-added zone tappable. The +read path M2's comment promised is real too: a zone the tzdata dropped is kept +and drawn with no time, no offset and no day (§16). + ### `stopwatch_laps` | Column | Type | Notes | @@ -529,6 +550,16 @@ 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. +**M8 adds a wall-clock surface.** The world clock is a calendar fact about +somewhere else, so it reads `WallClock.now()` and never `ElapsedRealtimeClock` — +the same column of the table above that alarms sit in. What is worth writing +down is the *cadence*: **both the instant and the device zone are re-read on +every tick**, from the injected `WallClock` and `ZoneProvider`. That is what +makes `TIME_SET` and `TIMEZONE_CHANGED` free for this tab — it self-heals +within one tick, and `SystemEventReceiver` needs no new branch. The zone never +comes from `ZoneId.systemDefault()` at a call site; a build rule (§8) fails on +one anywhere under `domain/` or `ui/`. + --- ## 6. Preferences @@ -560,6 +591,15 @@ value the user could never have chosen. Unknown enum names degrade to their default; a `home_zone_id` the device's tzdata no longer knows reads back as absent; blank strings read back as `null`. +`home_zone_id` got its **first reader and its first writer in M8**, two +milestones after the key was added. The reader is `HomeZone.resolve(stored, +deviceZone)`, which is the whole home-zone rule in one pure function — the +stored zone when the device still knows it, else the device's own — and the +writer is the World clock tab's top-bar action, which also clears it so home +follows the device again. Nothing is seeded: a fresh install has no stored home +zone and no world clock row, and the hero face simply shows the device's zone +(§16). + `timer_presets` is the same discipline over a *list*: one comma-joined string of millis, sanitised both ways through `TimerPresets.sanitise` — non-numeric entries dropped, anything outside 1 s…24 h **dropped rather than clamped** @@ -590,7 +630,7 @@ before the first frame, and the alternative is an app only "clear data" can fix. ## 7. Dependency injection -Hilt, `SingletonComponent` throughout. Eight app modules plus the kit's: +Hilt, `SingletonComponent` throughout. Nine app modules plus the kit's: | Module | Provides | |---|---| @@ -600,6 +640,7 @@ Hilt, `SingletonComponent` throughout. Eight app modules plus the kit's: | `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` | | `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` | | `RingtoneModule` | `@Binds` for the two ringtone seams: `RingtoneCatalog`, `RingtonePreviewer` | +| `ZoneModule` | `@Binds` for the one ICU seam: `ZoneNames → IcuZoneNames` (M8). `ZoneDirectory` needs no binding — it is a `@Singleton` `@Inject` class | | `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") | @@ -618,17 +659,24 @@ queries onto its own executor and runs Flow queries off the main thread, so a `withContext(io)` wrapper around a DAO call would be cargo cult. The DataStore half already runs on `@IoDispatcher`, wired once in `DataModule`. -`SystemRingtoneCatalog` is the one documented exception: it takes +`SystemRingtoneCatalog` is the first documented exception: it takes `@IoDispatcher`, because a raw `ContentResolver` query — and `RingtoneManager`'s cursor, and `MediaPlayer.prepare` in `SystemRingtonePreviewer` — dispatches nothing of its own and would otherwise block whichever thread asked. +`ZoneDirectory` (M8) is the **second**, for the same reason: building the zone +catalog is ~450 ids times three ICU calls, ICU dispatches nothing of its own, +and the build happens the first time a picker opens. It is also cached on the +locale tag — AppCompat's per-app language can change under a running activity — +and guarded by one `Mutex`, so two collectors opening the picker at once build +once. + --- ## 8. Testing -JVM-first. 843 unit tests run in the gate; 32 instrumentation tests compile in +JVM-first. 1 139 unit tests run in the gate; 40 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/`, `domain/ring/` or `domain/timer/`. @@ -755,6 +803,40 @@ real back press from the timer editor landing on a still-selected Timers tab. **They have not been run**, for the same reason M5's have not: no device is attached to the machine this milestone was built on. +**M8 kept it for a fourth screen**, and it was the milestone most tempted to +break it, because ICU and `Canvas` both look like they need a device. They do +not. Everything ICU answers is behind `ZoneNames`, faked in a unit test. +Everything the face *draws* is `DrawScope` calls over angles from `AnalogFace` +and a fraction from `DayNight` — two pure objects with no collaborators, so the +geometry is asserted at every minute of the day without a pixel. Everything the +list and the picker *decide* is pure too: `WorldClockRows`, `ZonePickerRows`, +`ZoneSearch`, `ZoneComparisons`, `ZoneCatalog` and `WorldClocks`. And everything +the tab *writes* is a `WorldClockViewModel` method, tested over the **real** +`WorldClockRepositoryImpl` across `FakeWorldClockDao` and the real +`SettingsPrefs` over a real DataStore under a `@TempDir`. + +One new fake and one new harness. `FakeZoneNames` is settable per method, counts +calls per method — which is what makes "the catalog is built once" and "the +display name is asked for once per DST phase" assertable — and carries a +`failEverything` flag that makes every call throw, which is what makes "the +directory never propagates an ICU failure" assertable. `WorldClockHarness` is +`StopwatchEngineHarness`'s arrangement for a tab with no engine. + +Three more `ArchitectureRulesTest` rules: `android.icu` is named only under +`data/zones/`, so the ICU seam stays one file; `MaterialShapes` and +`androidx.graphics.shapes` are named only under `ui/worldclock/`, which makes +`PLAN.md` §8's "not sprinkled everywhere" mechanical (the ring screen and timer +progress are sanctioned too, so widening the allowlist is a deliberate edit +rather than a drift); and no file under `domain/` or `ui/` uses +`ZoneId.systemDefault()` or `TimeZone.getDefault()` in code — the zone is a +parameter, from `ZoneProvider`. All three held retroactively for M0–M7's files. + +Four more instrumentation tests: the hero face really composing and its +semantics really reaching TalkBack, the picker's real `Dialog` and real IME +really adding a city, and the **Remove** and **Move up** accessibility actions +really working the way TalkBack would drive them. **They have not been run** — +no device is attached to the machine this milestone was built on. + Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with `useJUnitPlatform()` and `isReturnDefaultValues = true`. @@ -786,6 +868,15 @@ Time types: the domain speaks `kotlin.time.Instant`, `kotlin.time.Duration`, for M3's DST work; the DST arithmetic itself is `java.time`'s, wrapped in one function (§11). +M8 adds **one** dependency coordinate: `androidx.graphics:graphics-shapes`, +pinned at `1.0.1`, the version that already resolved transitively through +`androidx.compose.material3`. `MaterialShapes` lives in material3, but `Morph` +and `RoundedPolygon` are `androidx.graphics.shapes`, and the world clock's face +imports that package directly — a direct import on an undeclared transitive +dependency is how a Renovate bump breaks a build nobody declared. Pinning to +what already resolved means the dependency graph does not move in this +milestone. + ### Manifest Clockula's permission set is the alarm engine's, and nothing more. Each one is @@ -832,6 +923,10 @@ delivered regardless — so nothing else can fake a fire. `directBootAware`, and the database and DataStore live in credential-encrypted storage that is unreadable before the first unlock. +**M8 adds no permission and no manifest entry at all.** ICU, `java.time` and +Compose drawing need none: the world clock schedules nothing, wakes nothing, +rings nothing and posts no notification. + Components: `MainActivity`, the non-exported `CrashReportActivity` and `AlarmRingActivity`, two foreground services (`AlarmRingService` and M6's `TimerService`), five receivers — `AlarmFireReceiver`, `AlarmActionReceiver`, @@ -845,10 +940,8 @@ AppCompat's locale metadata holder service. | 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 | -| 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 | | 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 | -| 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 self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 | | A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | M10 at the earliest | @@ -865,6 +958,12 @@ AppCompat's locale metadata holder service. | 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 | +| A rename UI for `world_clocks.label`. The column is honoured on **read** — a row shows the label when it has one — but M8 adds no way to write it; M10's backup import will carry labels | M10 | +| An automatic "home" row while travelling. Google Clock's automatic home clock is a separate feature with its own preference and its own edge cases; M8's answer to "where am I" is the hero face, which already follows the device when no home zone is stored | not planned for v1 | +| A per-city detail screen, a map, sunrise/sunset times or a day-length bar. `DayNight` is a stated convention for a shape morph, not an ephemeris (§16) | not planned for v1 | +| Live times or long zone names in the zone picker. 450 rows of ICU long names is real work for a list the user scrolls past, and a ticking picker is a ticking picker | not planned for v1 | +| Reordering world clocks by anything but `sort_order` — no grouping by continent, no sort-by-offset, no pinning. The list is the order the user put it in | not planned for v1 | +| `MaterialShapes` on the ring screen or timer progress. `PLAN.md` §8 sanctions both; neither is rebuilt in M8, and the build rule (§8) makes widening the allowlist a deliberate edit | not planned for v1 | | Screenshots and store listing polish | M11 | Also absent by design: any seeded default data — no starter alarm and no home @@ -1829,3 +1928,203 @@ 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. + +--- + +## 16. The world clock + +The section to read before changing anything zone-shaped. + +### There is no engine, and there must not be one + +`AlarmEngine`, `TimerEngine` and `StopwatchEngine` exist because each owns a +platform registration that has to move with stored state — an AlarmManager slot, +a foreground service, a ring session. **A world clock owns none of them.** It +schedules nothing, wakes nothing, rings nothing and posts no notification, so a +fourth engine would be three empty seams and a `Mutex` guarding nothing. + +M8's shape is therefore the one §13/§14 describe for a *screen*: a pure state +builder (`WorldClockRows`), a `Source` that combines the repository, the prefs, +the directory, the clocks and the ticker into it, and a ViewModel that calls the +repository and `SettingsPrefs` directly. Every verb the tab has — `add`, +`remove`, `reorder`, `setHomeZone` — is a plain write with nothing to actuate +afterwards. + +### The ICU seam, and which half of tzdata is pure + +The device's tzdata gives two different things, and they belong on two different +sides of the boundary. + +**The ids** are `java.time.ZoneId.getAvailableZoneIds()` — plain JVM, and +therefore assertable in a unit test with no fake at all. They arrive through the +existing `ZoneProvider` seam as `available(): Set`, because a static +`getAvailableZoneIds()` at a call site is the same ambient read `PLAN.md` §5 +bans for `now()`, and a test needs to hand over a small set. + +**The names** — the exemplar city, the country and the long zone name — are +`android.icu`, which does not exist on the JVM test classpath. They go behind +**`ZoneNames`** in `data/zones/`, in exactly the shape `RingtoneCatalog` already +has. Every method is **total and never throws**: ICU on an unusual OEM build +must degrade a caption, not crash a tab, so `IcuZoneNames` wraps every body in +`runCatching` and reads a blank answer back as no answer. A build rule pins it: +`android.icu` appears only under `data/zones/`. + +`ZoneDirectory` is the `@Singleton` that joins the two. It runs on +`@IoDispatcher` (§7), caches the catalog on the **locale tag** under one +`Mutex`, and builds **lazily** — the tab resolves one entry per stored clock +through `entryFor`, and only the picker ever calls `entries()`. Those entries +are memoised here too, on the same locale tag, so a per-app language change +re-resolves the cities; a cache above this seam would keep the rows in the old +language while the ICU zone name switched. The long zone name is memoised on +`(locale, zoneId, DST phase)`, so a per-tick caller costs a map lookup and a tab +left open across a transition picks up "Summer Time" on the next tick instead of +going stale until the process restarts. Both memos are asked in **batch** +(`entriesFor`, `displayNamesOf`), so one tick costs two dispatches rather than +two per row. + +### The catalog: canonical, region-based ids, plus UTC + +`ZoneCatalog.pickableIds` keeps an id iff its first segment is one of the ten +IANA regions **and** it is its own canonical id. The first rule drops +`Etc/GMT+5`, `US/Pacific`, `SystemV/AST4` and `EST5EDT` — not city ids, and a +user picking "GMT+5" is picking a fixed offset that will be wrong in six months. +The second drops the `backward` aliases, so the picker offers **Asia/Kolkata** +and not also Asia/Calcutta, and the answer comes from the tzdata's own +canonicalisation through ICU rather than from a table of ours that would rot — +`PLAN.md` §0's second commitment, applied. An alias is only dropped when its +canonical id is itself available: ICU (`com.android.i18n`) and tzdata +(`com.android.tzdata`) are separately updatable APEX modules, and a device whose +tzdata still says `Europe/Kiev` while ICU already says `Europe/Kyiv` must not be +left with no pickable zone for Ukraine at all. + +**`UTC` is the one special case**, kept by name: it passes neither rule, and a +clock app that cannot show UTC is missing the one zone every traveller and every +log file shares. It is kept only when the device actually knows it; nothing is +invented. + +Search is **word-prefix over folded text**, not substring: NFD, combining marks +stripped, lowercased through `Locale.ROOT`, split on anything that is not a +letter or a digit. So "São Paulo" folds to "sao paulo", "york" and "new yor" and +"yor new" all find New York, "germany" finds Berlin through the country, and +**"erl" does not find Berlin** — a three-letter query must not turn into forty +unrelated hits. Filtering preserves the catalog's own order rather than ranking, +because the catalog is already sorted by city and an alphabet everybody can +predict beats a relevance score nobody can. + +### The offset and the day are read at the instant + +`ZoneComparisons.compare(home, other, at)` reads **both** zones' offsets at `at` +and subtracts. Nothing is stored, nothing is cached, and no arithmetic is done +on a "standard offset": + +``` +offsetMinutes = other.offset(at) − home.offset(at), in minutes +dayDifference = DAYS.between(home local date, other local date) +``` + +That is the discipline `AlarmOccurrences` uses for alarms (§11): let `java.time` +answer the transition. Berlin↔Sydney is **ten** hours apart in January and +**eight** in July, and a subtraction of standard offsets would get both wrong. +The day comes from the two local *dates*, not from the offset — Berlin and +Honolulu are eleven hours apart and still on the same calendar day. + +The labels are **data, not strings**, following `NextFireFormat`'s precedent: +`OffsetLabel.Same | Ahead(h, m) | Behind(h, m)` and +`DayLabel.YESTERDAY | TODAY | TOMORROW`. The wording lives in `strings.xml`, and +`h`/`m` are non-negative magnitudes with the sign carried by the variant, so +Kathmandu is `Ahead(4, 45)` and not a signed 285 the translator has to +interpret. `dayLabelFor` clamps to ±1 to stay total; the real spread between the +extreme zones is 26 hours, so ±1 is the only outcome tzdata can reach. + +### Home + +`HomeZone.resolve(stored, deviceZone)` is the whole rule: the stored zone when +the device still knows it, else the device's own. It is one pure function, so +the fallback is tested rather than implied, and `ClockPrefs.homeZoneId` already +normalises through `Zones` in both directions (§6), so a zone the tzdata later +dropped reads back as absent before `resolve` ever sees it. + +Consequences, all deliberate. **Nothing is seeded** — a fresh install shows the +device's zone on the face and an empty list, and the face is not a stored row. +**Home is set from this tab**, through the top-bar action, because a preference +the user cannot reach is not handling; M10 may surface it a second time. **The +home zone may also be added as a row**, which is not prevented: `add` is +idempotent on `zone_id`, and that row simply reads "Same time as home". + +### The face, and why its morph is information + +`PLAN.md` §8 sanctions a `MaterialShapes` morph on the analog face and warns it +must not be "sprinkled everywhere". M7 refused one on the stopwatch with the +sentence this has to answer: *a stopwatch has no progress, and a decorative arc +would be a lie drawn to two decimal places.* + +So the morph here **carries information**. The dial is +`Morph(MaterialShapes.Circle, MaterialShapes.Sunny)` and its progress is +`DayNight.fractionAt(localTime)` — a plain circle in the middle of the night +there, a sun at midday. "Is it the middle of the night where they are" is +precisely the question a world clock exists to answer, and answering it with +shape rather than a third line of text is what §8's "refinement comes from +shape, colour, space and motion" means. + +`DayNight` is a stated **convention, not an ephemeris**: 0 below 05:00, a linear +ramp to 1 at 07:00, 1 until 17:00, a linear ramp back to 0 at 19:00. It needs no +latitude, it has obvious tests, and the app never claims it is sunrise. + +The rest is ordinary `Canvas` work over `AnalogFace`'s pure angles — twelve hour +marks with the four quarters longer, three hands, a centre cap — in scheme +tokens only. The morph is built once and the path re-derived into a reused +`Path` per frame through `toPath(Morph, Float, Path)`, which is not +`@Composable` and is therefore callable from a `DrawScope`. The face is the home +readout and the digital time is **not** repeated under it: an analog face that +also prints its own time is a face nobody looks at. It carries a content +description naming the city and the time, and is deliberately **not** a live +region — at 1 Hz it would recite the clock for as long as the tab was open, +which is the mistake M4 already refused for the live pill (§12). + +### The cadence + +`WorldClockDefaults.Tick = 200 ms`. `RealTicker` is a plain `delay` loop with no +alignment to the wall second, so a one-second cadence would leave the second +hand up to a full second behind the device clock — visible, and not something a +clock app ships. The cost is bounded by making the state **stable**: +`HomeFaceState.time` is truncated to the second, `WorldClockRowState.time` to +the minute, and the flow is `distinctUntilChanged`. The upstream emits five +times a second, a new state is produced about once a second, and the rows' own +values change once a minute. The period is a `Ticker` parameter, never a `delay` +at a call site (§12). + +### Twenty-four cities, and a reorder a screen reader can reach + +The kit's `ReorderableColumn` is not lazy and pins rows to a 64dp pitch, so the +list needs a bound: **`WorldClocks.MAX = 24`**, one per hour of the day, a list +a person can take in at a glance. Past it the FAB carries "Add up to 24 cities" +as its description, the same sentence is printed below the list where a sighted +user can read it — M3's `ExtendedFloatingActionButton` has no `enabled` +parameter to dim it with, and a refusal only a screen reader can hear is a dead +button to everybody else — and the ViewModel refuses the add: the same shape as +`StopwatchLaps.MAX = 999` (§15): a constant with a reason, asserted as a +constant so a change is deliberate. + +A drag-only reorder is inaccessible. TalkBack cannot press-and-drag and neither +can a keyboard, and M4 settled the principle when the hold challenge grew an +accessibility `onClick`: *a challenge TalkBack cannot answer is exactly the +stranding the requirement forbids.* So every row carries three custom +accessibility actions — **Move up**, **Move down**, **Remove** — alongside the +handle and the long-press. The row also merges its descendants under one +**content description** — the city, the time and the summary as one sentence — +rather than letting TalkBack read three child texts in child order; that is +also the only place the "no longer on your device" sentence is spoken. The arithmetic behind the first two is pure +(`WorldClocks.moveUp`/`moveDown`), a no-op at the end it is already at and for +an id the list does not hold, and always a permutation of its input; a move that +changes nothing writes nothing. + +Remove is a long-press plus a confirmation that names the city, with +deliberately **no undo chip** — the same trade M5 and M6 made: the cheap moment +to ask is before. The pending id is `rememberSaveable`, so a rotation +mid-confirmation does not lose the question. + +A row whose zone the device's tzdata no longer knows shows **no time, no offset +and no day** — `WorldClockRowState` carries all three as `null`, non-null +exactly when `known` is true — and says "This time zone is no longer on your +device" instead. The row is kept, never rewritten: §4 already decided that, and +inventing a time for a zone nobody can resolve would be worse still.