diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e4de5..eb28598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -181,6 +181,23 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING - An alarm or timer set by voice without showing you a screen removes itself once it has been dismissed, so talking to your assistant every morning no longer leaves a list of dead 06:30 alarms behind. +- A settings screen, reachable from a gear icon on every tab: theme, dynamic + colour and language, the alarm/timer/clock defaults every new alarm or timer + inherits, help, and the app version. +- Back up your alarms, timers and world clocks to a JSON file in a folder you + choose, and restore them on this device or another one. Restoring replaces + everything currently on the device — the app says so before you confirm. + Nothing about a ringing alarm or a running timer's live state travels with + the backup, only the configuration. +- A "why might my alarm not ring?" screen that checks the actual state of your + device — exact-alarm and notification permissions, full-screen alerts, + battery optimisation, Do Not Disturb, your alarm volume, and whether the + system has actually scheduled Clockula's next alarm — and links straight to + whichever settings page would fix each one. +- Three home-screen widgets: your next alarm, a running or paused timer (with + pause, resume and reset right on the widget), and a clock that grows to show + your world clocks at larger sizes. Each one updates the moment something + changes — no polling, no stale numbers. ### Changed - Tapping the alarm icon in the status bar, or the next-alarm line on the lock diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c45cddb..db811e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -96,6 +96,9 @@ kit module — the kit's roadmap keeps schedulers app-local. | `domain/text/` | `TextFolding` (M9) — NFD-fold, strip combining marks, lowercase through `Locale.ROOT`, `containsFolded`. One copy, shared by the zone search and the label search | | `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function | | `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat` — the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller | +| `domain/backup/` | `BackupContent` (M10) — the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room | +| `domain/selfcheck/` | `SelfCheck` (M10) — the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` | +| `domain/widget/` | the three widget presenters (M10) — `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance | | `data/db/` | `ClockulaDatabase` — `@Database` v3, `exportSchema = true` — and `Migrations` | | `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories | | `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) | @@ -105,7 +108,8 @@ kit module — the kit's roadmap keeps schedulers app-local. | `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` | | `data/ringtones/` | the two ringtone seams — `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer` — plus their `System*` implementations | | `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` | -| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8 — one `@Binds`, `ZoneNames → IcuZoneNames`) | +| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8 — one `@Binds`, `ZoneNames → IcuZoneNames`), `BackupModule` (M10) | +| `data/backup/` | the backup's data-layer half (M10): `BackupDto`/`BackupCodec` (the pure JSON wire format, `ignoreUnknownKeys`, strict on known fields), `BackupDao` (one `@Transaction` replace-all over alarms/timers/world clocks — no schema or version change), `BackupRepository(+Impl)` — mapping through the existing `AlarmMapper`/`TimerMapper`/`WorldClockMapper`, never a second copy | | `alarm/` | `AlarmEngine` and the four seams it talks to — `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` — plus `AlarmIntents` | | `alarm/android/` | the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed notification | | `alarm/receiver/` | `AlarmFireReceiver`, `AlarmActionReceiver`, `SystemEventReceiver` | @@ -124,6 +128,8 @@ kit module — the kit's roadmap keeps schedulers app-local. | `stopwatch/di/` | `StopwatchModule` — `@Binds` for the one seam | | `interop/` | the `AlarmClock` boundary's Android half (M9): `AlarmClockActivity` (the one exported, permission-guarded, window-less door), `IntentExtrasReader` (`Bundle` → `IntentExtras`), `AlarmClockHandler` (the orchestrator — **no `android.*` import, ever**), `InteropIntents` (the internal transport to `MainActivity`) and `InteropLaunch` | | `system/` | `RebootRepair` — the boot-id gate | +| `backup/` | `BackupEngine` (M10) — the orchestrator: a ringing alarm dismissed and running timers deleted before a replace, `AlarmEngine.reschedule()`/`TimerEngine.resync()` after it — plus `BackupFiles`, the SAF seam, with its Android implementation in `backup/android/` | +| `widget/` | the three `GlanceAppWidget`/`GlanceAppWidgetReceiver` pairs (M10) and `WidgetSync` — the event-driven collector, started from `ClockulaApp.onCreate`, that redraws all three on any change to the state they mirror. `widget/di/` holds the `@EntryPoint` Glance needs, since a `GlanceAppWidgetReceiver` is never `@AndroidEntryPoint`-constructed | | `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface | | `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is **gone**: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller | | `ui/common/` | `rememberLocalTimeFormatter` — the app's **one** 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17) — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need | @@ -132,6 +138,8 @@ kit module — the kit's roadmap keeps schedulers app-local. | `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` | | `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` | | `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls | +| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel` — all reachable from a gear icon on every tab, with no owning tab of their own | +| `ui/widget/` | the three widgets' `@Composable` Glance content (M10) — the only Compose this milestone adds outside a tab; the `GlanceAppWidget`/`GlanceAppWidgetReceiver` classes themselves live in `widget/`, since they are manifest components, not UI | --- @@ -1025,11 +1033,17 @@ nothing flashes on the way through. Components: `MainActivity`, the exported and permission-guarded `interop.AlarmClockActivity`, the non-exported `CrashReportActivity` and -`AlarmRingActivity`, two foreground services (`AlarmRingService` and M6's -`TimerService`), five receivers — `AlarmFireReceiver`, `AlarmActionReceiver`, -`SystemEventReceiver`, `TimerExpiryReceiver`, `TimerActionReceiver` — and -AppCompat's locale metadata holder service. Exactly **two** of them are -exported, and `ManifestRulesTest` fails the build on a third. +`AlarmRingActivity`, three foreground services (`AlarmRingService`, M6's +`TimerService`, M7's `StopwatchService`), five non-widget receivers — +`AlarmFireReceiver`, `AlarmActionReceiver`, `SystemEventReceiver`, +`TimerExpiryReceiver`, `TimerActionReceiver` — AppCompat's locale metadata +holder service, and, since M10, three `GlanceAppWidgetReceiver`s +(`NextAlarmWidgetReceiver`, `TimerWidgetReceiver`, `ClockWidgetReceiver`). +`ManifestRulesTest` pins the exported set exactly: the two above, plus the +three widget receivers — each with **only** the platform-required +`APPWIDGET_UPDATE` filter and no permission, since `APPWIDGET_UPDATE` is a +platform-delivered broadcast no other app can trigger by naming the action. +M10 adds no ``. --- @@ -1037,22 +1051,19 @@ exported, and `ManifestRulesTest` fails the build on a third. | Not here | Milestone | |---|---| -| The exact-alarm and full-screen-intent grant deep links, and any explanation of a denial. M4 asks for `POST_NOTIFICATIONS` once on first launch; the rest waits for the self-check screen, because a bare jump into system settings with no reason given is hostile | M10 | | Reordering alarms by hand. The list is ordered by time of day (§13) and `alarms` has no `sort_order` column, so this would be a schema change for a preference the time order already answers | not planned for v1 | -| A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; this one is still global and still `AlarmRing.AUTO_SILENCE_AFTER` | M10 at the earliest | +| A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; this one is still global and still `AlarmRing.AUTO_SILENCE_AFTER` | not planned for v1 | | Voice-interaction follow-on flows: `Activity.isVoiceInteraction`, `VoiceInteractor.CompleteVoiceRequest` (publishing a deeplink to the alarm just created) and `PickOptionRequest` (disambiguating by voice). `android.intent.category.VOICE` is therefore not declared — advertising it while answering with a touch dialog would be a promise the app does not keep. Clockula *accepts* a `clockula://` deeplink and publishes none | not planned for v1 | | A "most closely matched" time search for `DISMISS_ALARM`. `ALARM_SEARCH_MODE_TIME` matches **exactly**: dismissing an alarm the caller did not name is a missed alarm, and there is no distance at which that becomes a good trade | not planned for v1 | | A toast or any other confirmation on a `SKIP_UI` write. `SKIP_UI` means "bypass any intermediate UI", a voice assistant speaks its own confirmation, and the status-bar alarm icon `setAlarmClock` lights is the platform's own receipt (§17) | not planned for v1 | | Surfacing `delete_after_use` in either editor. It is a property the contract sets, not a setting; an alarm the user edits keeps it, and that consequence is documented (§17) rather than papered over with a checkbox | not planned for v1 | | Carrying `EXTRA_MESSAGE` into the timer setup panel when no length was given. There is no row to carry it on and the panel has no label field | not planned for v1 | | Verifying a ringtone URI at the intent boundary. M5 locked "an unreadable sound is reported, never rewritten"; an unmounted card comes back, and the boundary opens no `ContentResolver` | not planned for v1 | -| Exporting `delete_after_use` in a backup. There is no backup yet; M10's JSON schema must include the column | M10 | -| The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 | -| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | M10 at the earliest | +| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | not planned for v1 | | Reordering timers by hand. `sort_order` and `TimerRepository.reorder` exist and stay caller-less: the list is in storage order, M5 gave the same answer for alarms, and a drag surface would also mean abandoning the `LazyColumn` | not planned for v1 | -| Per-timer vibrate, volume-ramp or dismiss-challenge overrides. `timers` has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for | M10 at the earliest | +| Per-timer vibrate, volume-ramp or dismiss-challenge overrides. `timers` has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for | not planned for v1 | | A "stop all" for several expired timers, and undo after a timer delete. Each expired timer is acknowledged on its own, and the delete confirmation asks before rather than after | not planned for v1 | -| A presets management screen, and editing `default_timer_duration_millis` or `default_timer_ringtone_uri`. M6 reads both and offers the two-gesture preset surface on the setup panel | M10 | +| A presets management screen (add/remove the quick-start durations themselves). M10's settings screen edits `default_timer_duration_millis` and `default_timer_ringtone_uri`, but the preset list is still only editable from the two-gesture surface on the setup panel | not planned for v1 | | A timer that ramps its volume, a dismiss challenge or a snooze for a timer | not planned for v1 | | Surfacing `TimerSnapshot.anchorIsStale` in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 | not planned for v1 | | Lap export, sharing, deletion or editing, and any "previous run" history. A run's laps belong to the run that produced them; keeping old runs is a different feature with its own table | not planned for v1 | @@ -1062,7 +1073,7 @@ exported, and `ManifestRulesTest` fails the build on a third. | A countdown-to-start, split-lap alarms, or any sound or vibration from the stopwatch. It is silent, and a build rule says so (§8) | not planned for v1 | | An analog stopwatch face or a `MaterialShapes` showpiece on the Stopwatch tab. `PLAN.md` §8 sanctions the showpiece for the analog world-clock face, the ring screen and timer progress; a stopwatch has no progress to be a fraction of | not planned for v1 | | Surfacing `StopwatchReading.anchorIsStale` in the UI, for the same reason the timers' is not: the repair makes it momentary, and the value displayed is already honest | not planned for v1 | -| A rename UI for `world_clocks.label`. The column is honoured on **read** — a row shows the label when it has one — but M8 adds no way to write it; M10's backup import will carry labels | M10 | +| A rename UI for `world_clocks.label`. The column is honoured on **read** — a row shows the label when it has one — and M10's backup now carries it on export/import, but there is still no way to *write* a label in-app | not planned for v1 | | An automatic "home" row while travelling. Google Clock's automatic home clock is a separate feature with its own preference and its own edge cases; M8's answer to "where am I" is the hero face, which already follows the device when no home zone is stored | not planned for v1 | | A per-city detail screen, a map, sunrise/sunset times or a day-length bar. `DayNight` is a stated convention for a shape morph, not an ephemeris (§16) | not planned for v1 | | Live times or long zone names in the zone picker. 450 rows of ICU long names is real work for a list the user scrolls past, and a ticking picker is a ticking picker | not planned for v1 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c13bd86..57cf529 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,20 +10,19 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started ## Current state (one line) -✅ **M9 done.** Every app on the device — an assistant, an automation app, a -watch companion, a shell script — can now drive Clockula through -`android.provider.AlarmClock`, and no extra it sends can make Clockula write -something the user did not ask for. All **seven** of the contract's actions are -answered through **one** exported, permission-guarded, window-less door, behind -which everything that decides is pure Kotlin: an hour of 25 is dropped rather -than clamped to 23, a snooze of 1 000 minutes is clamped rather than refused, a -`file://` ringtone inherits the default instead of blocking the alarm, and an -intent hostile in every extra at once still leaves exactly one alarm and the -user looking at it. A voice-set alarm is **transient** — the schema went to v3 -for it — so "wake me at 6:30" every morning no longer leaves a graveyard of dead -06:30 rows. And the status-bar alarm icon finally opens the alarms list instead -of a ring screen for an alarm that is not ringing. **M10** (settings, JSON -backup and the self-check screen) is next. +✅ **M10 done.** Clockula now has a settings hub (theme, dynamic colour, +language, the alarm/timer/clock defaults every new alarm or timer inherits, +help), a documented, versioned JSON backup (`docs/BACKUP.md`) that exports and +restores alarms/timers/world clocks and settings through SAF — configuration +only, never ring state, never a timer's running anchors — as a whole-state +replace the screen is explicit about before the user confirms, a "why might my +alarm not ring?" self-check that reads real device state (exact-alarm and +notification permissions, full-screen intent, battery optimisation, DND, alarm +volume, a known aggressive-OEM allowlist, and the system's own next alarm +compared against what Clockula computed) and deep-links to the exact settings +page for each, and three Glance home-screen widgets — next alarm, timer, +clock — that mirror existing state and redraw event-driven, on every write, +with no ticker of their own. **M11** (release readiness) is next. --- @@ -586,7 +585,7 @@ next-alarm publishing. Hostile-input validation at the intent boundary. hardware**, as no device was attached — the same caveat M5's seven, M6's four, M7's four and M8's four carry. -### ⬜ M10 — Settings, backup, the self-check, and widgets +### ✅ M10 — Settings, backup, the self-check, and widgets - Settings composed from kit components: theme, dynamic colour, language, alarm/timer/clock defaults, about + crash reporting. - JSON backup export/import via SAF (`PLAN.md` §7), schema documented in