Commit Graph
70 Commits
Author SHA1 Message Date
makiolaj 5d11921c19 build: pin androidx.graphics:graphics-shapes
The analog face imports `androidx.graphics.shapes.Morph` directly rather than
inheriting it transitively from Material 3. Pinned to the version that already
resolved, so nothing about the resolved graph changes — only its honesty.
2026-09-22 13:05:04 +02:00
makiolaj a7a5b64137 feat(zones): ICU behind a seam, and one directory that remembers
`ZoneNames` is the seam; `IcuZoneNames` is the only file in the app allowed to
name `android.icu`, and a build rule keeps it that way. Every read is guarded —
ICU returning blank or throwing gives back null rather than a half-written
city.

`ZoneDirectory` caches the catalog, the entries and the display names, all
keyed on the locale tag, so a per-app language change re-resolves every name
instead of leaving the cities in the old language under a freshly translated
zone name. It answers in batches, so a tab with two dozen cities costs two
dispatches a tick rather than two dozen.
2026-09-22 13:05:04 +02:00
makiolaj e0d5f1f254 feat(worldclock): the catalog, the search, the comparison and the face's geometry
The pure-Kotlin half of the world clock, with no Android on it anywhere and no
ambient zone read — the device's zone arrives as an argument, and an
architecture rule now enforces that.

`ZoneCatalog` de-duplicates the device's own tzdata by canonical id and keeps
the region ids, with `UTC` as the one stated exception — and keeps an alias
whose canonical id the device does not have, because tzdata and CLDR ship as
separate modules and can disagree. `ZoneSearch` matches word prefixes over
diacritic-folded text, so "sao" finds São Paulo and "erl" does not find Berlin.
`ZoneComparison` reads the offset and the day difference **at the instant**, so
Berlin to Sydney is ten hours in January and eight in July. `AnalogFace` turns
an instant into hand angles and says whether it is day or night there.

`Zones.isValid` now tests a snapshot rather than allocating the platform's set
on every call; tzdata takes effect at reboot, so the answer cannot change under
a running process.
2026-09-22 13:04:54 +02:00
makiolaj 8dec91fd4c docs: mark M7 done 2026-09-22 10:24:12 +02:00
makiolaj cb35cc9c95 docs: the stopwatch, and why its reading is floored and banked
`ARCHITECTURE.md` gains §15 for the stopwatch and records the one correctness
find this slice turned up: a lap taken inside a segment a reboot later
discarded would have dragged the readout, the lap totals and the shade
backwards. The reading is floored at the last lap's total — and the floor is
banked, not merely displayed, or the shade's free-running chronometer walks
away from a tab that is standing still.

The package, module, receiver, service and permission tables all count one
more; §10 loses the two rows M7 closed.
2026-09-22 10:24:08 +02:00
makiolaj 83995c39db test(arch): the stopwatch stays silent, and tabular figures stay in the theme
Two new rules and two extended. The stopwatch may not name a player, a
vibrator, an audio manager — or a wake lock: it is the one timekeeper in the
app that never makes a sound and never holds the CPU awake, and that is now
a build failure rather than a promise in a comment.

`fontFeatureSettings` may appear only under `ui/theme/`, so the figure
settings stay in one place instead of spreading to call sites. The
Android-free list gains the engine; the screen's needle list gains the
service and its notifications.
2026-09-22 10:24:08 +02:00
makiolaj 5e73e81dc3 refactor(shell): the pill's stopwatch half goes through the engine
M6 left this as M7's: the pill wrote to the stopwatch repository directly
because there was no engine to ask. There is one now, so the pill's buttons
mean exactly what the tab's and the shade's mean, service transitions
included.

`LivePillSelector` is re-pointed at `StopwatchReadings` — the same extraction
M6 did for the timers — and its existing cases pass unmodified, which is the
proof that the reading moved rather than changed. A stopwatch notification
now opens the Stopwatch tab.
2026-09-22 10:24:08 +02:00
makiolaj 7b727e0d40 feat(stopwatch): the tab — the readout, the laps, and the fastest and slowest
A fixed readout over a lap list, newest first, the lap in progress counting
as its own row once there is a lap to compare it against. The fastest and
the slowest are marked in `primary` and `tertiary` — never `error`, and never
by colour alone: each carries a spoken marker so the emphasis survives being
read aloud.

The state rebuilds only what actually moves: the recorded rows and their
emphasis come off the lap table once, and the tick recomputes the hero
figure and the row in progress. Laps stop at 999 and the readout ticks only
while the stopwatch is running.
2026-09-22 10:23:54 +02:00
makiolaj 2aaae5dde8 feat(theme): tabular figures on the roles that carry a running number
M0 said the big-readout typography would be settled once there was a
stopwatch to settle it against. `ClockulaTypography` now puts `tnum` on the
display, headline and title roles, which is every role a counting number
uses, and `ClockulaReadoutDefaults` names the three sizes the readouts share.

Digits stop changing width as they change, so the timer row, the setup panel
and the live pill stop twitching too — none of them needed a call-site
change to get it.
2026-09-22 10:23:54 +02:00
makiolaj b667e46877 feat(stopwatch): the foreground service, its silent notification and the receiver
The service is alive exactly while the stopwatch is not idle, and it is
`specialUse` with the subtype spelled out — reusing `systemExempted` would
have the app claim it is continuing alarm functionality, which it is not.
No wake lock, ever: the notification carries the platform chronometer
counting up from a base derived when it is posted and never stored, so the
system draws the ticking and the process can sleep through it.

Its channel is `IMPORTANCE_LOW` because nothing here ever alerts. The Lap,
Pause, Resume and Reset buttons are broadcasts carrying no extras — the
receiver reads the stored run rather than trusting an intent — and the body
opens the Stopwatch tab. Boot, time change and package replacement all reach
the third engine now, alongside the other two.
2026-09-22 10:23:44 +02:00
makiolaj 7f8b2e79fc feat(stopwatch): the engine, its one seam and the verbs the shade can press
A third engine beside the alarms' and the timers', and a small one: four
verbs behind a single mutex, no scheduler and nothing that rings. Start,
pause, lap and reset are here rather than on the repository because three of
them arrive as notification buttons and must mean the same thing whichever
surface pressed them.

The verbs guard on the *reading's* mode, not the stored row, so a run whose
anchor a reboot invalidated resumes when the tab says Resume instead of
silently doing nothing; resuming banks the last lap's total first, so the
readout never stands still and never walks backwards. `StopwatchIntents`
namespaces the actions and hands out request codes that cannot collide with
the timers'. The service is reached through one seam, which keeps the engine
free of Android and lets the tests watch the transitions in order.
2026-09-22 10:23:36 +02:00
makiolaj 68728e5e7f feat(stopwatch): the readout, the readings and what the shade is told
The pure-Kotlin half of the stopwatch, with no Android on it anywhere.

`StopwatchFormat` splits a duration into a major field it asks `ClockFormat`
for and two truncated hundredths, so the fraction can be drawn smaller than
the seconds beside it. `StopwatchReadings` turns a stored run plus the
monotonic clock into the one reading the tab, the pill and the notification
all draw — a run whose anchor belongs to a previous boot reads paused at what
it banked, never negative and never below the last lap's total. `LapStats`
marks the fastest and the slowest, but only once three laps exist, with ties
going to the earlier lap and an all-equal set marking neither.
`StopwatchNotification` says what the shade shows without knowing what a
`Notification` is.
2026-09-22 10:23:25 +02:00
makiolaj 6ea11f7bbd docs: mark M6 done 2026-09-12 16:49:05 +02:00
makiolaj 319d176ae8 docs: the timers, and the counts checked against the gate
ARCHITECTURE gains §14 for timers and the ring package's new shape. The test
counts were written before the review's fixes landed; they now match what the
gate actually runs.
2026-09-12 16:49:05 +02:00
makiolaj fac9250ec7 refactor(alarms): point the editor at the moved ringtone picker
Completes the move: the old ui/alarms copies are gone and the editor imports
the shared one. Timers pick a sound the same way alarms do.
2026-09-12 16:48:48 +02:00
makiolaj b67142a727 feat(timers): the timers tab, and the pill that finally has something to show
Multiple concurrent timers with labels, presets, add, pause, reset and +1 min.
The setup panel is inline on an empty tab, so the first timer costs no
navigation.

The live pill has been waiting since M4 for something that could start a
timer. It now goes through the engine rather than the repository, because a
scheduler registration and a foreground service have to move with the state —
its own contract is unchanged.

Three new architecture rules keep it that way: no screen may name the
scheduler, the service or AlarmManager, and a second MediaPlayer anywhere
outside the ring path fails the build.
2026-09-12 16:48:36 +02:00
makiolaj 7f9219e929 feat(timer): the engine, its expiry slot, the service and the receivers
One AlarmManager slot for every timer, registered on ELAPSED_REALTIME_WAKEUP
so the clock the domain anchors on is the clock the platform wakes on. Its
fire carries no id and is an idempotent sweep, backed up by a second trigger
inside the service, because neither has to be reliable on its own.

The foreground service posts per state change rather than per second — the
platform chronometer draws the countdown — and its actions are broadcasts, so
pause and reset still work with the process dead. Every verb is guarded
against a stale id.

Expiry reuses the alarm's audio path: the same player, vibrator, source policy
and fallback-to-vibration chain, with a zero ramp and its own channel. No
full-screen intent, no challenge, no snooze — a timer is not an alarm. Two
timers expiring together share one ring; a timer expiring after a previous
one's auto-silence window lapsed gets a sound of its own, which is the whole
point of having a timer.
2026-09-12 16:48:36 +02:00
makiolaj 19efb67740 feat(data): timer writes that survive a clock change and a reboot
"+1 min" on a timer that has just rung resumes it with exactly a minute, in
one transaction — the user asked for a minute more than zero, not a minute
more than an anchor that has gone by. M2 left that branch paused with no test
to pin it; this is the gesture a timer app is judged on.

Every running row carries both anchors: the monotonic one it actually runs on,
and a wall-clock fallback for a reboot. A system clock change re-derives the
fallback from the monotonic remainder rather than leaving it stale, because a
stale fallback is how a thirty-minute timer rings twenty-nine minutes early
after a reboot.

Presets are app-wide, sanitised on read as well as on write, and go through
the store's update so two edits cannot swallow each other.
2026-09-12 16:48:21 +02:00
makiolaj a23010c41b feat(domain): the timer readings, expiry, ring and notification policies
One ordering for all of it. The pill, the notification and the ring used to be
three places that each decided which timer mattered most; TimerReadings is now
the single answer, and the pill's own test passing unmodified is the proof the
extraction changed none of M4's behaviour.

Expiry is a pure function of state, which is what makes the arbitration with a
ringing alarm symmetric: the alarm wins the audio, and the timer's ring is
deferred rather than lost. Durations stay anchored to elapsed realtime, so
moving the system clock cannot move an expiry.
2026-09-12 16:48:21 +02:00
makiolaj 5430e0c778 refactor: lift the ring machinery out of the alarm package
The audio player, the vibrator, the volume ramp and the audio-source policy
were never alarm-specific — timers need the same ones, and a second
MediaPlayer in the app would be a bug. Moved and renamed, no behaviour
changed: the moved tests differ by their package line and one constant name.

The ringtone picker moves to ui/common for the same reason.
2026-09-12 16:48:07 +02:00
makiolajandClaude Opus 5 5a01efb7bf docs: mark M5 done
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 14:44:23 +02:00
makiolajandClaude Opus 5 176626b33a docs: the alarms screen, and the numbers put right
ARCHITECTURE gains §13 for the alarms screen. The test counts in §8 were
written before the review's fixes landed, and M4's instrumentation count was
one short; both now match what the gate actually runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 14:44:23 +02:00
makiolajandClaude Opus 5 97ca617e6a feat(alarms): the list and the editor
The tab the app has been missing. Rows carry the time, the repeat summary and
the next-fire countdown, ordered by time of day rather than by when they fire
next — a row must not move under the thumb reaching for it, so the next alarm
is marked by colour instead. The editor covers time, repeat days, label,
ringtone, vibration and snooze, with the five per-alarm overrides as
three-state pickers: a switch cannot say "I have not chosen".

Two things that look like details and are not. A ringing alarm is dismissed
through the engine before it is disabled, edited or deleted — otherwise its
state is cleared while the service keeps sounding and the auto-silence backstop
returns early on its own guard, leaving the alarm ringing until the wake-lock
timeout. And clearing the last repeat day disarms a pending skip, because
otherwise the resolver disables the alarm and the control that would have
explained it is already hidden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 14:43:46 +02:00
makiolajandClaude Opus 5 4662aee90d refactor(ui): move the alarm time formatter where both screens can reach it
The ring screen wrote it first; the alarms list needs the same 12/24-hour
rendering. Moved rather than copied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 14:43:46 +02:00
makiolajandClaude Opus 5 b5ec07b470 feat(alarm): a refresh cadence for the upcoming list, still read-only
`upcoming` takes the cadence it re-resolves on, so a live countdown comes from
the engine's own resolution — the grace window, the watermarks and the DST
walk stay in one place instead of being copied into a screen. Re-resolving
writes nothing, which is asserted rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 14:43:33 +02:00
makiolajandClaude Opus 5 431c4c2697 feat(data): the device ringtone catalogue and a single-tone previewer
The catalogue closes its cursor and survives a device with no alarm tones at
all: the picker is never empty, because "Silent" and the app default are
always offerable. A chosen URI that later becomes unreadable is disclosed on
the row rather than quietly rewritten — the user should know their alarm
cannot play what they picked.

The previewer serialises behind a mutex and releases a tone that finished
preparing after a stop, so two taps cannot leave two alarm sounds playing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 14:43:33 +02:00
makiolajandClaude Opus 5 8002651e3f feat(data): a transactional alarm edit, and the audio source policy
The editor and the engine write to the same row from different threads, so an
edit is a transaction: read, transform, write back, with the id and the
creation stamp forced from the stored row. Without it the editor would
re-enable an alarm the engine had just resolved as disabled, or move a row's
history.

`AudioSourcePolicy` is where the silent sentinel becomes an empty source list
— the audio player then fails to start by design, and the ring falls through
to vibration instead of to nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 14:43:33 +02:00
makiolajandClaude Opus 5 52204d3e9c feat(domain): next-fire text, repeat summaries, ringtones and defaults
The countdown on a row is formatting, not scheduling: it turns the engine's
resolved instant into "in 9h 12m", rounding whole minutes up so a row never
reads a minute it has already passed. Repeat masks become their own short
summaries with the week starting where the locale says it does.

A ringtone is a URI, plus one explicit silent sentinel — "Silent" has to be a
choice a user can make, and it resolves to no audio source at all, which is
what forces vibration rather than a quiet alarm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 14:43:17 +02:00
makiolajandClaude Opus 5 2905747a23 build: bump floret-kit to 0.6.0 for the scaffold's FAB slot
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 14:43:17 +02:00
makiolajandClaude Opus 5 983c9acaa7 build: the saved-state view-model dependency for the editor's tests
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 14:43:17 +02:00
makiolajandClaude Opus 5 e8fceec240 docs: mark M4 done
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 13:08:05 +02:00
makiolaj d4120d8e06 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
2026-09-12 13:07:53 +02:00
makiolaj 0b31f7c9c4 test(arch): keep the shell's decisions out of the composables
The rules that let M4 ship without Robolectric are only true while they stay
true, so they are asserted rather than trusted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 13:07:53 +02:00
makiolaj 959eb599ff feat(ring): the ring screen, over the lock screen
Shows over the lock screen, turns it on, keeps it on, and swallows back for
the whole lifetime of the window — including while the session is still
loading, which is where a back press used to finish the activity and leave
the alarm sounding with nothing on screen to stop it.

The optional dismiss challenge cannot strand the user: the standing wrong
answer clears the moment they start typing the next one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 13:07:18 +02:00
makiolaj 83c5e9fe3f feat(shell): the navigation host, four tabs and the live pill
`NavigationSuiteScaffold` gives the bar on a phone and the rail on a wide
layout from one declaration. Each tab keeps its own back stack; re-selecting
a tab returns to its root, and predictive back is wired only where back
actually goes somewhere.

The pill is the running-state surface from PLAN §9: it floats above the bar,
speaks for whichever subject is closest to needing attention, and pauses or
stops it from any tab. Its callbacks are gated on a live subject, so a second
tap during its exit animation cannot restart a timer the user just reset.

The notification permission is asked once, and the flag is written from the
request's result rather than before it — a process death mid-dialog leaves
the ask due again rather than recorded and never made. Three tabs are empty
shells until M5 through M8 fill them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 13:07:18 +02:00
makiolaj 199005e795 feat(data): ui preferences, the real ticker and its binding
The ticker is injected rather than read from the composition so the shell's
per-second text can be driven by a test scheduler instead of a wall clock.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 13:07:04 +02:00
makiolaj 6dee033aba feat(domain): the live pill's subject, clock formatting and the challenge gate
Which of a running timer, a running stopwatch and a snoozed alarm the pill
speaks for is a pure selection over the repositories' flows, ordered so the
thing closest to needing attention wins. Countdown text truncates where a
stopwatch rounds up, because a timer reading 0:01 must still have a second
left in it.

The dismiss challenge is monotone in both escape routes — neither a wrong
answer nor a re-roll can make the way out shorter — so a half-awake user is
never stranded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 13:07:04 +02:00
makiolaj a1bf6170d2 test: shared fakes, a fake ticker and the main-dispatcher extension
The view-model tests need repositories they can drive and a clock they can
advance. One set of fakes for the whole shell, not one per test class.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 13:06:53 +02:00
makiolaj d5e2d5045e build: bump floret-kit to 0.5.0 for the optional back affordance
The four tabs are navigation destinations, not pushed detail screens, so they
pass no `onBack`. Without this pointer bump a fresh clone resolves the kit at
0.4.0, where `onBack` is required, and CI fails at configuration time while a
dirty local submodule builds fine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 13:06:53 +02:00
makiolaj f862825161 build: the adaptive navigation-suite dependency
The shell's navigation bar and its rail on wide layouts are one component,
`NavigationSuiteScaffold`, pinned to the material3 version already in the
catalogue rather than a version of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
2026-09-12 13:06:53 +02:00
makiolajandClaude Opus 5 12da168cc5 docs: mark M3 done
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:05:58 +02:00
makiolajandClaude Opus 5 a522d68098 docs: the alarm engine, and the blind spot it closes
ARCHITECTURE gains a section 11 on the engine itself — the state machine, the
two AlarmManager slots, the DST table, and the chain that keeps a denied
permission from turning into a silent morning. Section 5's known blind spot is
amended rather than deleted: the boot id exists now, and the stopwatch repair
moved from M7 to here.

Section 9's "no permissions are declared yet, deliberately" is finally untrue,
so it is replaced with the real set and why each one is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:05:31 +02:00
makiolajandClaude Opus 5 c0de363db5 test(arch): keep the alarm engine free of Android
The engine and the domain stay plain Kotlin, which is the only reason the
DST, skip and snooze behaviour can be pinned by 326 JVM tests instead of an
emulator. That is worth a test that fails the build when it stops being true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:05:25 +02:00
makiolajandClaude Opus 5 bf511b47d7 feat(alarm): the ringing service, and never degrading to silence
A systemExempted foreground service holds the ring: audio focus, USAGE_ALARM,
the volume ramp, vibration, and the ringtone URI. It goes foreground
synchronously before it reads anything from the database, because the platform
kills a service that reaches startForeground late — including on the path where
there turns out to be no session to ring.

The chain that matters is the one that never ends in silence. A full-screen
intent when canUseFullScreenIntent() allows it; a high-priority heads-up
notification that still rings when it does not; the device's default alarm
ringtone when the configured URI will not open; and forced vibration when no
audio source will play at all.

Preparing a MediaPlayer is done off the main thread — a synchronous prepare at
the moment the alarm fires is an ANR — and a player is released rather than
orphaned on every failure path, so a dismissal cannot be outlived by the ring
it cancelled.

The ring screen here is plumbing and a placeholder: window flags to show over
the lockscreen and turn the screen on. Its UI is M4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:05:25 +02:00
makiolajandClaude Opus 5 dd9a920c28 feat(alarm): the receivers — fire, boot, time set, zone and replace
All five are thin: inject, goAsync, make one call into the engine, finish in a
finally. Anything resembling a decision belongs in the engine where it can be
tested without a device.

TIME_SET and TIMEZONE_CHANGED both mean the same thing to the engine — every
resolved instant is now suspect — so both re-resolve from scratch rather than
trying to patch the registrations they already made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:05:12 +02:00
makiolajandClaude Opus 5 d256fc115f feat(alarm): setAlarmClock scheduling with the exact-alarm ladder
Only the single next alarm is ever registered, via setAlarmClock, so the
platform draws the status-bar icon and the alarm is exempt from doze. A second
slot holds the auto-silence backstop, kept deliberately separate so cancelling
one cannot cancel the other.

USE_EXACT_ALARM is declared for the versions that grant it outright, with a
SCHEDULE_EXACT_ALARM fallback path behind canScheduleExactAlarms() above that
boundary. Capabilities are exposed as a snapshot; asking the user for the
permission is M4's job and explaining it is M10's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:05:12 +02:00
makiolajandClaude Opus 5 edbbf0070d feat(alarm): the engine — fire, ring, snooze, dismiss, re-resolve
The engine owns every write and talks to Android through four interfaces it
does not implement, which is what keeps it a plain Kotlin class with 54 JVM
tests over fakes rather than something that needs a device to exercise.

At most one alarm rings, and a newly-firing alarm takes over — so closing a
cycle only touches the shared ring and auto-silence slots when that alarm is
the one actually ringing. Dismissing a snoozed alarm from its notification must
not silence a different alarm mid-ring.

Every read-modify-write over alarm_states runs under one mutex. A cold start
triggered by the fire broadcast otherwise races its own reschedule pass and can
overwrite ringingSince, which silences the alarm that woke the process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:05:03 +02:00
makiolajandClaude Opus 5 92483cac4b feat(system): repair running timers and the stopwatch after a reboot
Closes M2's known blind spot. On the first pass of a new boot, every RUNNING
timer has its monotonic anchor rewritten from its wall-clock fallback — one
already past its end becomes EXPIRED, one with no fallback becomes PAUSED at
its banked remaining. The stopwatch keeps no wall-clock fallback at all, so it
is paused at what it had banked rather than being allowed to invent a segment
it never ran.

The gate is persisted, so the repair runs once per boot and survives a process
death in between. ARCHITECTURE §5 booked the stopwatch half of this to M7; it
belongs here with the timer half, and the roadmap is amended to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:04:53 +02:00
makiolajandClaude Opus 5 57605f22f6 feat(core-time): a persisted boot id, and a zone read afresh each time
M2 left a hole it wrote down: once a new boot's uptime climbs past a stored
elapsed-realtime anchor, the reboot goes unnoticed and a running timer counts
down from an anchor that died with the last boot. Detecting it needs an
identity for the boot, which is what this is — Settings.Global.BOOT_COUNT,
with a derived-instant fallback for the devices that will not give it up.

The fallback is best-effort and is documented as such rather than dressed up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:04:53 +02:00
makiolajandClaude Opus 5 0beb1749fc feat(domain): the ring policies and the volume ramp
Both are pure functions so they can be tested without a device. The ramp in
particular: a fade-in is the kind of thing that is easy to get subtly wrong and
impossible to notice until an alarm opens at full volume or never reaches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:04:40 +02:00
makiolajandClaude Opus 5 e471c9b750 feat(domain): next-fire resolution, with skip and snooze watermarks
The resolver is pure: it reads an alarm and its ring state and returns what
should happen, and the engine does every write. Two rules keep re-resolution
honest, and both are load-bearing enough to be worth naming.

The grace window lets a fire that arrives a couple of minutes late still count,
so a device powered on at 07:01 rings its 07:00 alarm. The handled-occurrence
watermark suppresses a candidate only when it is both at-or-before the
watermark and at-or-before now — the second conjunct is what stops a user who
set the clock forward, let an alarm fire, then set it back from having every
future occurrence silenced forever.

Skip needs a watermark of its own, because a bare flag eats a second alarm once
the skipped occurrence has passed. On a one-shot alarm, skipping the only
occurrence is dismissing it, so it disables the alarm instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:04:40 +02:00
makiolajandClaude Opus 5 6e40b72c50 feat(domain): DST-correct occurrence generation over a repeat mask
Occurrences are generated by walking local dates forward and mapping each
through ZonedDateTime.of, never by adding 24 hours to an instant. That is the
whole DST story: java.time's default resolver shifts a 02:30 alarm forward to
03:30 on a spring-forward night rather than dropping it, and takes the earlier
of the two 02:30s on a fall-back night rather than ringing twice.

Asserted for Berlin and New York, and across all 128 repeat masks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:04:40 +02:00
makiolajandClaude Opus 5 f4b7c1a16c feat(data): ring state in a new alarm_states table at schema v2
Snooze, the handled-occurrence watermark and the skip watermark need somewhere
to live that survives a process death. They go in their own table rather than
as columns on alarms: a missing row is the initial record, and the FK cascade
means deleting an alarm cannot leave orphaned ring state behind.

Schema v2 is exported and committed alongside v1, so MIGRATION_1_2 is
reviewable and testable rather than taken on faith.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:04:26 +02:00
makiolajandClaude Opus 5 5ea3c798b4 docs: mark M2 done
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:53:42 +02:00
makiolajandClaude Opus 5 971ee4f7a3 docs: point the reboot repair at the milestone that actually owns it
The roadmap puts the BOOT_COMPLETED and TIME_SET receivers, the ringing
service and the full-screen intent in M3; ARCHITECTURE.md credited them
to M6 and M7, which are Timers and Stopwatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:53:11 +02:00
makiolajandClaude Opus 5 efd88e3060 docs(changelog): record the data layer
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:52:33 +02:00
makiolajandClaude Opus 5 1e6443c907 docs: the first ARCHITECTURE.md
How Clockula is built today: the layers and the data seam, the package
layout, the four tables column by column, dependency injection, and the
JVM-first testing posture.

The section on the two clocks is the one a future contributor will need
most — which aggregate resolves against which clock and why, the timer's
three anchors, and the reboot window where a dead anchor can still read
as live, stated as it actually behaves rather than as it was first
assumed to. Closing that window needs a persisted boot id; M3's
BOOT_COMPLETED receiver is where it belongs.

Also says plainly what is not built yet, and points each at its milestone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:52:33 +02:00
makiolajandClaude Opus 5 19b0719066 test(arch): keep Room and query strings inside the data layer
The seam is only worth having if something checks it. This fails the
build if androidx.room, an entity or a query string appears outside
data/, rather than leaving it to review to notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:52:33 +02:00
makiolajandClaude Opus 5 d2ab99867e feat(di): Hilt modules for the database, repositories and clocks
The database and its DAOs, the four repository bindings, and the Android
implementations of the two clocks — so nothing constructs a Room database
or reads a system clock by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:52:33 +02:00
makiolajandClaude Opus 5 6b7a4d73ed feat(data): Flow-based repositories over the DAOs
The four interfaces the rest of the app will talk to. They speak domain
types and Flows only — no entity and no query string crosses this seam.

Every read-modify-write goes through a DAO transaction rather than a
find-then-update, so the ringing service marking a timer expired cannot
silently lose the minute a user just added. Adding a world clock that is
already there returns the existing row instead of the insert sentinel.

Tested on the JVM against fake DAOs over MutableStateFlow with injected
fake clocks, so repository behaviour needs no device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:52:22 +02:00
makiolajandClaude Opus 5 11b220aba0 feat(prefs): clock defaults and the stopwatch run record in DataStore
Alarm and timer defaults, world-clock preferences, and the stopwatch's
running state — the things that are settings rather than rows.

The run record is read and written as a whole in one DataStore
transaction, so a reader never sees a half-applied record and a process
killed mid-write cannot persist one.

Values are clamped on read: a preferences file someone has edited by hand
degrades to the default instead of propagating nonsense upwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:52:22 +02:00
makiolajandClaude Opus 5 1d85faa54e feat(data): entity and domain mappers
The translation either way, including the repeat mask's bit order and the
nullable per-alarm overrides that mean "inherit the app default" rather
than a concrete value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:52:11 +02:00
makiolajandClaude Opus 5 778d08af24 feat(data): the Room database, its four tables and their DAOs
alarms, timers, world_clocks and stopwatch_laps, with the schema exported
to app/schemas and committed so migrations can be tested from v1 onwards.

The DAOs return Flows for reads and keep every multi-step write inside a
@Transaction, so a read-modify-write cannot lose a concurrent one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:52:11 +02:00
makiolajandClaude Opus 5 a2beae94ec feat(domain): plain-Kotlin models and the two clocks
The aggregates Clockula stores — alarms, timers, world clocks and the
stopwatch run — as ordinary Kotlin, with no Room or Android type anywhere
near them.

Time is taken as a parameter, never read ambiently: WallClock and
ElapsedRealtimeClock are separate types so a call site has to name which
one it means. A running timer resolves against the monotonic clock and
keeps a wall-clock value only as a post-reboot fallback; the stopwatch
gets no wall-clock value at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:52:02 +02:00
makiolajandClaude Opus 5 bd8654bf31 chore: keep the loop's scratch directory out of the repo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:47:46 +02:00
makiolajandClaude Opus 5 29b213efc6 docs: mark M1 done
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:47:46 +02:00
makiolajandClaude Opus 5 52f3296dc7 feat(theme): follow the stored theme mode and dynamic colour
The theme now reads the persisted preference instead of the device night flag
alone. The system bar styles are resolved from the same value and re-applied
when it changes, so a light theme forced under a dark system no longer draws
white status bar icons onto a light background.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:47:26 +02:00
makiolajandClaude Opus 5 fc615e4622 feat(prefs): app preferences over the kit's PrefStore
SettingsPrefs exposes the appearance slice; DataModule builds the DataStore on
the kit's @IoDispatcher. The store is created with a corruption handler that
replaces an unreadable file with empty preferences, so a truncated
clockula_prefs.preferences_pb cannot leave the app crashing on every launch
with no recovery short of clearing app data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:47:26 +02:00
makiolajandClaude Opus 5 2f2a2c529d build: depend on the kit's core-prefs and core-di
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:47:20 +02:00
makiolajandClaude Opus 5 3730d62ab5 build: bump floret-kit to 0.4.0 for core-prefs and core-di
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:47:20 +02:00