Merge branch 'feat/23-world-clock-seconds' into release/v1.0.0
# Conflicts: # app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt # app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt # app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt # app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt # docs/ARCHITECTURE.md # docs/BACKUP.md
This commit is contained in:
commit
fd7f5e8dcd
40 files changed
+860
-110
No files matched your search
+63
-15
@@ -594,9 +594,11 @@ 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. **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
|
||||
device zone are re-read on every tick**, from the injected `WallClock` and `ZoneProvider`. Since #23 the
|
||||
tick is a minute long unless seconds are on, so `TIME_SET` and
|
||||
`TIMEZONE_CHANGED` also restart it through `ClockChanges`, a receiver the tab
|
||||
registers while it collects (§16, "The cadence"); `SystemEventReceiver` still
|
||||
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/`.
|
||||
|
||||
@@ -605,12 +607,13 @@ one anywhere under `domain/` or `ui/`.
|
||||
## 6. Preferences
|
||||
|
||||
One DataStore file, `clockula_prefs`, behind floret-kit's typed `PrefStore`.
|
||||
Four slices:
|
||||
Six slices:
|
||||
|
||||
| Slice | Keys | Owner |
|
||||
|---|---|---|
|
||||
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs`, the family's shared key names |
|
||||
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_timer_vibrate`, `default_timer_volume_ramp_seconds` (#22), `default_dismiss_challenge`, `home_zone_id`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override) | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
|
||||
| World clock display (#23) | `world_clock_show_seconds` | `ClockPrefs`, surfaced as its own `SettingsPrefs.worldClockSeconds`, not a clock default, so flipping it re-emits nothing alarms and timers watch |
|
||||
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
|
||||
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
|
||||
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
|
||||
@@ -1619,6 +1622,16 @@ 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.
|
||||
|
||||
`alignedTicks(unit)` is the same seam for a readout of whole wall-clock units:
|
||||
after the immediate emission it waits `WallBoundaries.untilNext(now, unit)`,
|
||||
re-measured against `WallClock` after every wake, so it fires on the second or
|
||||
minute boundary itself and a `delay` that ran long does not accumulate. A
|
||||
clock set mid-wait is only caught up once that wait ends, so a caller on a
|
||||
long cadence restarts the flow on `ClockChanges`, the system's own word that
|
||||
the clock or zone moved. The world clock is its one caller (§16, "The
|
||||
cadence"), and does exactly that, so a long
|
||||
cadence never means a long wait for those.
|
||||
|
||||
### The dismiss challenge, and why it cannot strand anyone
|
||||
|
||||
`ChallengeGate` is a pure state machine whose single boolean `open` means
|
||||
@@ -2646,8 +2659,8 @@ 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, all in scheme
|
||||
tokens. The morph is built once and the path re-derived into a reused
|
||||
marks with the four quarters longer, the hour and minute hands (and a second
|
||||
hand when seconds are on, below), a centre cap, all in scheme tokens. 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`. M8 left the digital
|
||||
time out from under the face on the grounds that an analog face that also
|
||||
@@ -2663,15 +2676,50 @@ 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, which is 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).
|
||||
**Display time with seconds** (Settings > Clock, `world_clock_show_seconds`,
|
||||
off by default, #23) decides how often the tab wakes. Off, the face has an hour
|
||||
and a minute hand, the readout under it and every row show hours and minutes,
|
||||
and the tab ticks **once a minute**. On, the readout and the rows add seconds
|
||||
in the same 12/24-hour and locale form (`HH:mm:ss`, or the locale's medium
|
||||
time), the face gains its second hand, and the tab ticks **once a second**.
|
||||
The second hand belongs to the switch rather than being always there: a hand
|
||||
that steps every second is exactly what makes the tab wake every second, and
|
||||
the point of the switch is that nobody pays for that without asking.
|
||||
|
||||
Both cadences are `Ticker.alignedTicks` (§12), at
|
||||
`WorldClockDefaults.tickFor(showSeconds)`, which is derived from the same
|
||||
`unitFor` the readouts are truncated to, so a tick lands on the wall-clock boundary itself rather than
|
||||
up to a period late; a free-running one-second `delay` could leave the second
|
||||
hand a full second behind the device clock. This replaces M8's 200 ms
|
||||
free-running tick, which got within a fifth of a second only by waking five
|
||||
times as often as anything changed. `HomeFaceState.time` and
|
||||
`WorldClockRowState.time` are truncated to the same unit the tab ticks in, and
|
||||
the flow stays `distinctUntilChanged`, which now only absorbs the odd early
|
||||
wake. The setting is read with the stored list and the home zone, so flipping
|
||||
it switches the cadence at once through the same `flatMapLatest`.
|
||||
|
||||
A minute is too long to wait for `TIME_SET` or `TIMEZONE_CHANGED`, which can
|
||||
arrive with the tab on screen (a network zone change on landing, split
|
||||
screen). So `ClockChanges` (`AndroidClockChanges`, a receiver registered only
|
||||
while the tab collects) restarts the tick: those two make the state re-read
|
||||
the clock and the zone at once, and the restarted ticker measures its next
|
||||
wait against the clock as it now reads. The signal is combined
|
||||
with the stored list, the home zone and the setting *outside* the
|
||||
`flatMapLatest`, so the receiver is registered once per subscription, not
|
||||
once per list edit. Each broadcast costs one extra `build`, which
|
||||
`distinctUntilChanged` drops when nothing moved. The screen also sends a
|
||||
refresh on every resume, merged into the same signal. A per-app language
|
||||
change recreates the activity but not the ViewModel and is announced by no
|
||||
broadcast, so the city names would otherwise wait for the next minute to
|
||||
change language. And an aligned `delay` has one gap of its own, a deep sleep
|
||||
inside the five-second `WhileSubscribed` grace, which stretches the wait
|
||||
because the main looper's clock stops while the device sleeps; the tab
|
||||
cannot be seen again without a resume, which re-reads the clock.
|
||||
|
||||
TalkBack is given the time to the minute even with seconds on: the row's
|
||||
description and the home readout's semantics use the minute formatter, and
|
||||
the ticking text is kept out of the tree, because a focused node whose text
|
||||
changed every second would be re-announced every second.
|
||||
|
||||
### Twenty-four cities, and a reorder a screen reader can reach
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
|
||||
"default_alarm_silence_after_minutes": 10,
|
||||
"default_timer_silence_after_minutes": 10,
|
||||
"volume_buttons_while_ringing": "CONTROL_VOLUME",
|
||||
"world_clock_show_seconds": false,
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
|
||||
"bedtime": {
|
||||
"enabled": false,
|
||||
@@ -199,6 +200,10 @@ hour). A file without them, from before the setting existed, imports as `10`,
|
||||
the value it was made with. A value of 60 or more reads as 0, "Never" (which
|
||||
rings for that long anyway); a negative one reads as the default, 10.
|
||||
|
||||
Files from before `world_clock_show_seconds` existed import with it off, its
|
||||
default. It travels with the settings but is not a clock default: nothing an
|
||||
alarm or timer inherits depends on it.
|
||||
|
||||
Every numeric/duration value is clamped on *apply* using the same ranges and encoding
|
||||
as the existing preference setters, never on parse.
|
||||
|
||||
|
||||
+3
-1
@@ -453,7 +453,9 @@ deliberate showpiece.
|
||||
January and eight in July; labels are data, not strings, so Kathmandu is
|
||||
`Ahead(4, 45)` rather than a signed 285 for a translator to interpret; the
|
||||
cadence is 200 ms with the state truncated to the second (face) and the
|
||||
minute (rows) and `distinctUntilChanged` over it; the list is capped at
|
||||
minute (rows) and `distinctUntilChanged` over it (since #23, a tick aligned
|
||||
to the wall clock: once a minute, or once a second with "Display time with
|
||||
seconds" on); the list is capped at
|
||||
24, with the cap spelled out on the screen once it is reached, because
|
||||
M3's `ExtendedFloatingActionButton` has no `enabled` to dim; and
|
||||
reduce-motion snaps the morph but never stops the second hand,
|
||||
|
||||
Reference in new issue
Block a user