`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
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
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
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Clockula's seed is #6B7A5C, the third step of the family's colour cycle.
Calendula's slate (#5C6B7A) and Agendula's mauve (#7A5C6B) are the same three
bytes rotated — and that byte rotation is exactly a 120° hue rotation, so the
three seeds sit at 210°, 330° and 90° with identical saturation and lightness.
The fallback schemes are therefore not eyeballed: they are Agendula's
hand-picked scheme with its hue rotated the same 120°, which keeps the three
apps structurally one palette turned rather than three colours picked. The
cycle closes at three.
The launcher icon keeps the family's Calendula bloom badge path-for-path and
swaps the task card for a clock dial, drawn as a 308° arc so the badge
overhangs into a gap rather than colliding with the stroke — the same device
Agendula uses to open its card's corner.
MainActivity is M0 scaffolding: theme plus the crash surface, with a
placeholder where the four-tab shell and the live pill land in M4. The theme
follows stored preferences from M2, once there are any.
The manifest declares no permissions yet, deliberately. Clockula's permission
set belongs to the alarm engine and is declared in M3 alongside the receivers
and the ringing service that need it, so the manifest never claims a
capability the code cannot honour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L94fydiJC37LtxVusNQBDy
Copied from Agendula, which is the family's current template: AGP 9.x
conventions, the shared version catalog, and the F-Droid reproducibility
invariants that are load-bearing rather than cosmetic — no foojay toolchain
resolver anywhere, vcsInfo off on release, dependenciesInfo out of the APK.
floret-kit comes in as a git submodule wired up as a Gradle composite build,
so the kit is built from source and pinned by commit, exactly as its
ARCHITECTURE.md prescribes.
Two deliberate divergences from Agendula's catalog:
- Room replaces Glance. Clockula owns its storage (docs/PLAN.md §0) and ships
no widget in v1 (§1, decision 2). The schema is exported to a committed
directory, because a migration is only reviewable against a recorded
previous version.
- :provider and core-reminders are dropped. There is no vendored task
provider here, and Agendula's reminder lead-time model is not the shape
Clockula's scheduling takes.
Version starts at 0.1.0 / 100.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L94fydiJC37LtxVusNQBDy