docs: the world clock, its seam and its caches

`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.
This commit is contained in:
2026-09-22 13:05:24 +02:00
parent 815a14235b
commit 36f6b356d0
2 changed files with 326 additions and 13 deletions
+14
View File
@@ -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.
+312 -13
View File
@@ -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<String>`, 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.