Commit Graph
102 Commits
Author SHA1 Message Date
makiolaj 0f5cf4f1eb fix(widgets): config saves, live world clocks, quick-timer guards
- Config screens share WidgetConfigActivity: a picker that dismisses right
  after selecting no longer finishes with RESULT_CANCELED (which removed a
  newly placed widget on API 29/30), and the save runs to completion.
- Digital config shows only once stored settings are read, so an early OK
  or a late load can't write defaults over them.
- Digital widget collects world clocks inside the composition, so edits
  reach a live session.
- Quick timer: a second tap while its timer is active starts nothing;
  durations are capped at 59:59 (1 h preset dropped) to fit the readout.
- Analog date hand is redrawn at local midnight and on time/zone changes
  (AnalogDateRefresh); updatePeriodMillis back to 0.
- WidgetSync routes each flow only to the widgets that read it.
- Stopwatch widget uses the ElapsedRealtimeClock seam and has a two-row
  minimum height.
- Remove dead quick-timer helpers; share showWorldClockIntent.
2026-10-02 18:18:04 +02:00
makiolaj 82fff19d3d feat(widgets): M3 Expressive widget lineup — analog, digital, stopwatch, quick timer
Six widgets, each with minimal settings, styled with M3 Expressive shapes and
Material You colours, and sized proportionally to the placed widget
(SizeMode.Exact + LocalSize):

- Analog clock (new): nine faces on MaterialShapes dials (Round, Numeral,
  Day, Cookie, Sunny, Scallop, Clover, Square, Petal) with per-face hands,
  a seconds dot and Google-Clock-style orbiting date on some faces, and a
  day/date pill on others. Style picker previews inflate the real views.
- Digital clock: rebuilt as RemoteViews faces (Classic, Thin, Bold, Pill,
  Stacked) shared by the widget and its config previews; world clocks are
  opt-in and only on the card faces.
- Next alarm and Timers: shape badges, icon buttons, proportional readouts;
  a lone timer on a tall widget gets a stacked layout.
- Stopwatch (new): large readout with play/pause pill and lap/reset.
- Quick timer (new): a Scallop that is a timer of its own — tap to start a
  transient timer of its configured duration, tap again to reset/stop.

Colours resolve in the launcher (widget_* palette resources, GlanceTheme's
dynamic colours) so widgets follow day/night and the wallpaper. Data widgets
collect their flows inside the composition so a live Glance session never
shows stale state, chronometer bases use elapsedRealtime, and config
activities close cleanly on an invalid widget id.
2026-10-02 18:01:11 +02:00
makiolaj 5d03c654bf docs: mark M10 done
ARCHITECTURE.md's package table gets the new domain/backup,
domain/selfcheck, domain/widget, data/backup, backup/, widget/,
ui/settings/ and ui/widget/ entries, and §9/§10 are brought up to date
with what M10 actually built (the exported-component count, the
permission list unchanged, which 'not built yet' rows are now done).
CHANGELOG and ROADMAP's current-state line follow.
2026-10-01 22:34:07 +02:00
makiolaj fb81bfbf2e feat(widgets): next alarm, timer and clock home-screen widgets
Three Glance widgets, mirroring state the app already computes rather
than owning any of their own: the next alarm (AlarmEngine.upcoming()),
the live pill's subject timer (same selection logic, no ticker of its
own — a running countdown is the platform chronometer, exactly like
the ring notification), and the local time growing to world clocks at
larger sizes. WidgetSync collects the same read-only flows the shell
already reads and redraws on change, event-driven (updatePeriodMillis
= 0) — it touches no engine, only re-renders, and covers every write
path including SystemEventReceiver's since the process is already
alive whenever those run.

Tapping a widget reuses the same internal navigation actions the
AlarmClock contract's door already produces; the timer buttons send
the same unexported broadcasts its notification does. A widget is
just another caller of machinery that already exists.

ManifestRulesTest is updated deliberately: three GlanceAppWidgetReceivers
join the exported set, each pinned to exactly the platform-required
APPWIDGET_UPDATE filter and no permission — no new permission is
requested anywhere in this change.
2026-10-01 22:32:07 +02:00
makiolaj a11396b058 feat(selfcheck): why might my alarm not ring?
A pure rule table (domain/selfcheck/SelfCheck.kt) over real device
state: exact-alarm and full-screen-intent permission, notification and
alarm-channel state, battery-optimisation exemption, background
restriction, DND (read-only — Clockula never requests
ACCESS_NOTIFICATION_POLICY or touches DND itself), alarm stream
volume, a known aggressive-OEM allowlist, and the system's own next
alarm compared against what Clockula itself computed. Each row maps to
a deep link into the exact settings page responsible.

FSI denial and a missing battery exemption warn rather than fail —
both degrade (to heads-up, and to a doze-exempt setAlarmClock
registration) rather than silence the alarm. The settings hub's
'Why might my alarm not ring?' row now shows the live problem count.
2026-10-01 22:15:42 +02:00
makiolaj 616bf7f67f feat(backup): JSON export/import through SAF, whole-state replace
clockula-backup-v1.json, schema documented in docs/BACKUP.md per
PLAN.md §7: alarms, timers and world clocks as configuration only —
ring state and a timer's running anchors never leave the device, since
neither means anything on another one. Unknown fields are ignored at
any level; a malformed known field rejects the whole file rather than
importing it half-way.

Import is whole-state replacement, not a merge, and the backup screen
says so before the user confirms. Before replacing: a ringing alarm is
dismissed and every running timer is deleted, so the import can never
strand a live ring session; after it, AlarmEngine and TimerEngine are
told to re-resolve so the imported state is live immediately.

BackupDao adds delete-all/insert-many queries under one transaction —
no schema or version change, queries only.
2026-10-01 22:06:44 +02:00
makiolaj 7253445c4e feat(settings): a settings hub — appearance, alarm/timer/clock defaults, data, help
Reachable from a gear icon on every tab, composed from kit components
(GroupedRow/OptionPicker/AboutCard/LanguagePickerRow) following
Calendula's hub shape: About card first, then headed groups. Every
control writes immediately — no Save button, same as the alarm editor.

Appearance: theme, dynamic colour (API 31+), language. Alarm and timer
defaults reuse the editor's own ringtone and override pickers
(OverridePickers gets an includeAppDefault flag so the settings rows
don't offer an 'inherit' option that has nothing above it to inherit
from) and the world clock's home-zone picker. Reliability and Data
rows are placeholders for the self-check and backup screens landing
next. Help surfaces the issue tracker, a pending crash report (the
same dialog MainActivity already shows on launch), and the app
version.

Settings has no owning tab — ShellNavigation.selectedDestinationOf
deliberately leaves it unmapped, so the shell falls back to Alarms
while it's open, same as any other unrecognised route.
2026-10-01 17:36:06 +02:00
makiolaj 0664791ac5 test(manifest): fix ManifestRulesTest permission-list drift
POST_PROMOTED_NOTIFICATIONS was added to AndroidManifest.xml for the
timer's Live Update chip but never added to this test's expected
permission list — pre-existing drift, unrelated to M10, caught while
touching this file for settings work.
2026-10-01 17:35:55 +02:00
makiolaj 3266807ffe build: add kotlinx.serialization and Glance for M10
A pure-Kotlin JSON codec (so the backup round-trip test runs on the
JVM) and Jetpack Glance (the three M10 widgets). Both resolve from
google()/mavenCentral() only, no verification-metadata file exists to
update, and the repro guard checks vcsInfo/dependenciesInfo/foojay —
none of which this touches.
2026-10-01 17:23:24 +02:00
makiolaj c8cc156691 docs: bring home-screen widgets into M10, out of post-v1
Widgets share M10's machinery end to end: the next-alarm and timer
state a widget mirrors is exactly what the settings/backup/self-check
work already touches, and a widget tap is just another caller of the
MainActivity door the AlarmClock contract already opens. Keeping them
parked in post-v1 would mean building the same read paths twice.

QS tile, screensaver and bedtime stay deferred — none of them shares
M10's surface the way widgets do.

Also tightens PLAN.md §4's DND wording (interruption-filter read, no
ACCESS_NOTIFICATION_POLICY) and §7's backup semantics (whole-state
replace, ring state excluded, Room column vocabulary) ahead of M10's
implementation.
2026-10-01 17:22:07 +02:00
makiolajandClaude Sonnet 5 d47220947b feat(timers): redo the add-timer flow as a full-screen calculator keypad
The Timers tab gets a full rewrite of how a timer is created, after Google
Clock as the interaction reference (PLAN.md §11):

- A dedicated TimerSetupScreen replaces the bottom-sheet/inline setup panel:
  a per-unit "00h 00m 00s" readout, a "00" key, and a big centred play
  button that never shifts position when Clear fades in beside it.
- The keypad grows into the thumb zone on its own screen (92dp keys, pushed
  toward the bottom), and both it and the readout read bold.
- Presets are dropped from the flow; TimerDurationEntry gains the matching
  domain support (hoursSegment/minutesSegment/secondsSegment,
  plusDoubleZero()).
- An empty timer list shows the same readout/keypad/Start inline, in place
  of a "tap + to add" card — the keypad is still the empty state, just the
  redesigned one.
- Timer cards are disconnected like the Alarms tab's own list, each with an
  instant, unconfirmed delete via a small corner button (re-creating a
  timer is faster than reading a confirmation dialog would be).
- Hero readouts go bold app-wide to match: stopwatch, alarm rows, world
  clock, the ring screen, and both editors' length/time displays
  (ClockulaReadoutDefaults.Hero and matching call sites).

Alongside: Android 16 Live Update support for the running-timer notification
(POST_PROMOTED_NOTIFICATIONS, ProgressStyle), the live pill restyled to a
plain tonal circle, the alarm dismiss-chooser dialog moved onto Floret's
OptionCard, and small fixes to addTime's zero/negative-result handling,
repeat-day selection and alarm routes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 17:06:54 +02:00
makiolaj 3ff74dc1d5 docs: mark M9 done
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Failing after 6m50s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
2026-09-23 11:02:24 +02:00
makiolaj 9ab3927efe docs: the system boundary, and what it refuses
`ARCHITECTURE.md` gains §17: the door, the split between the Android-free
validator and the reader that guards the unparcel, the range rule (clamp what
is happening now, drop what would define a future ring), the candidate table
for "which alarm did you mean", and the three deliberate narrowings — including
why a link we cannot read dismisses nothing rather than everything.

§4 records schema v3 and its migration; §13 records the reading of `PLAN.md` §6
this milestone builds, since a malformed intent lands the user in an editor
over a row created from the valid extras, there being no "new alarm" sentinel.
2026-09-23 11:02:20 +02:00
makiolaj 46f625b6b2 test(arch): the platform's intents stay at the door, and the show intent stays pointed
Three new rules. Nothing under `domain/` may name `android.content.Intent`, so
the validator stays testable without a device. The `AlarmClock` action strings
may be named only under `domain/interop/` and `interop/`. And
`AndroidAlarmScheduler` may not name `AlarmRingActivity` — the defect this
milestone fixed would otherwise come back the next time someone reaches for a
"show the alarm" intent.
2026-09-23 11:02:20 +02:00
makiolaj 46a049f988 feat(alarms): ask which alarm, when the request names more than one
"Dismiss my alarm" with two alarms set is a question, not an instruction. When
the search matches more than one the app asks, in a dialog listing the
candidates; when it matches exactly one it acts without asking.

The Timers tab's setup effect no longer re-keys on a list that changes with
every readout tick.
2026-09-23 11:02:20 +02:00
makiolaj 02f3794ee8 feat(shell): navigation the system can ask for, read once per launch
An incoming request is more than a tab now — it can open an editor, compose a
timer or ask which alarm to dismiss. So it is read once, when the activity is
created rather than on every configuration change: re-deriving it on a rotation
would drag the user back into an editor they had just left.

The extras are unparcelled behind a guard here too. `MainActivity` is exported,
and before this it only ever read the action, which never unparcels.
2026-09-23 11:02:06 +02:00
makiolaj e55f3d19d4 feat(interop): one exported door for the whole AlarmClock contract
`AlarmClockActivity` is the only exported surface that takes a platform intent,
guarded by the caller-side `SET_ALARM` permission — which cannot go on
`MainActivity`, because the system checks it against the Launcher too. It is
invisible, does its work, and finishes.

Two intent-filters, not one: a filter carrying a `<data>` element never matches
an intent without data, so the deeplink actions need a filter of their own.
`IntentExtrasReader` turns the `Bundle` into a plain map and guards the
unparcel, because an exported door is reachable by anything on the device and a
hostile Parcelable must not crash the launch.
2026-09-23 11:02:06 +02:00
makiolaj 15dfaf300b fix(alarm): point the next-alarm show intent at the app, not the ring screen
The `AlarmClockInfo` show intent — what the status-bar alarm icon and the
lockscreen's next-alarm line open — targeted `AlarmRingActivity` with
`CLEAR_TASK`. Tapping it opened a ring screen for an alarm that was not
ringing, which resolved to "finished" and closed itself again.

It now opens the app on its Alarms tab. That show intent is what "next-alarm
publishing" means, so the rule pinning it belongs to this milestone.
2026-09-23 11:01:54 +02:00
makiolaj 72b788b589 feat(engines): dismiss and snooze from outside, under the same lock
The verbs an assistant can ask for go on the engines, not on a handler that
orchestrates repositories behind their backs. `dismissUpcoming` and
`snooze(id, minutesOverride)` move the ring slot and the backstop;
`dismissExpired` and `dismissAllExpired` move the timer's expiry registration.
Each of those is why they belong under the engine's mutex.

A snooze duration a caller sends is clamped to one hour, because that one is
the user's own request happening now. An alarm or timer marked to delete
itself is deleted as its cycle closes rather than left disabled.
2026-09-23 11:01:54 +02:00
makiolaj 05fc6e437f feat(interop): the AlarmClock vocabulary, and a validator with no Android in it
The platform's contract as constants, and everything that decides what an
incoming intent *means* — parsed, validated and range-checked as pure Kotlin,
so hostile input can be tested without a device.

`IntentExtras` reads a map rather than a `Bundle`, because the platform's typed
getters cannot tell "absent" from "wrong type" and that distinction **is** the
validation. The range rule: clamp what the user is doing now, drop what would
define a future ring — hour 25 clamped to 23 rings at an hour nobody chose, so
it is dropped and the editor opens instead.

A link we cannot read names nothing, rather than falling back to "all of them":
a caller who mistypes one timer's id must not dismiss every expired timer the
user left standing. A repeat list sent non-empty from which nothing survives
leaves the alarm incomplete, because a repeating alarm silently becoming a
one-shot is a missed alarm next week.
2026-09-23 11:01:42 +02:00
makiolaj b43d9117ab refactor(text): lift diacritic folding out of the zone search
M8 folded text so "sao" would find São Paulo. M9 needs the same fold to match
an alarm by its label, so the fold moves to `domain/text/` and the zone search
calls it rather than owning it.
2026-09-23 11:01:42 +02:00
makiolaj 74a6295543 feat(data): schema v3 — an alarm or a timer that deletes itself after use
The `AlarmClock` contract says twice that an alarm or a timer created with
`SKIP_UI` should be removed once it has been dismissed. Without somewhere to
record that, a voice user's alarm list becomes a graveyard of one-shots nobody
asked to keep.

One column on each of `alarms` and `timers`, defaulting to 0, and a migration
that adds them. Existing rows keep the behaviour they have always had.
2026-09-23 11:01:31 +02:00
makiolaj cd26fefc69 docs: mark M8 done 2026-09-22 13:05:24 +02:00
makiolaj 36f6b356d0 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.
2026-09-22 13:05:24 +02:00
makiolaj 815a14235b test(arch): ICU, MaterialShapes and the ambient zone each stay where they belong
Three new rules. `android.icu` may be named only under `data/zones/`, so the
platform's name database stays behind its seam. `MaterialShapes` and
`androidx.graphics.shapes` may be named only under `ui/worldclock/`, so the
showpiece stays the one place that morphs. And nothing under `domain/` or
`ui/` may read `ZoneId.systemDefault()` or `TimeZone.getDefault()` — the
device's zone is an argument, or the tests cannot pin a single one.

All three hold retroactively for M0 through M7.
2026-09-22 13:05:18 +02:00
makiolaj 725682d851 refactor(shell): delete EmptyTabScreen
Its own KDoc said M5 to M8 would replace it. M8 was the last one: every tab
now has content of its own, so the placeholder has no caller left.
2026-09-22 13:05:18 +02:00
makiolaj 26f1effb4f feat(worldclock): the tab, the picker, and the face that answers at a glance
A hero face for home over a list of cities, each carrying its time, its offset
and how many days apart it is from you. The dial morphs between a circle and a
sun with the hour *there*: the one showpiece M0 reserved, spent on information
rather than decoration — a glance says "it is the middle of the night for them"
with no sentence needed.

The picker searches the device's own zones by word prefix. The list reorders by
drag **and** by move-up/move-down actions, because a drag-only reorder is
unreachable by a screen reader; each row reads as one sentence rather than
three texts in child order. Home is set by hand or left following the device.
At the twenty-fourth city the refusal is written on the screen, not only in the
content description.
2026-09-22 13:05:18 +02:00
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