feat(alarms): schedule an alarm on a date, pause a repeating one
A one-shot alarm can now ring on a chosen date, and a repeating alarm can be paused for a range of days. Both are local dates: the date is a floor and the pause a hole in the occurrence sequence, so DST and zone changes resolve as for any other alarm. A missed date behaves like a missed one-shot, and each clears itself once it can no longer change a candidate. Picking a repeat day clears the date, as in Google Clock; "skip next" stays the quick version of a pause. The editor uses the M3 date and date-range pickers, which offer no past day. The list, the next-alarm widget and the self-check read the resolver, so they follow. The schema goes to v4 with a migration, and the backup format to v2, which still imports v1 files. SET_ALARM no longer re-uses an alarm that carries a date or a pause. Fixes #21
This commit is contained in:
1 parent
3f431a7f32
commit
b1a45db54a
43 files changed
+2488
-74
No files matched your search
+61
-6
@@ -48,7 +48,7 @@ through.
|
||||
│ entities │ typed prefs
|
||||
┌──────────────┴─────────────┐ ┌─────────┴───────────────┐
|
||||
│ DAOs + mappers (Room) │ │ PrefStore (DataStore) │
|
||||
│ ClockulaDatabase v3 │ │ clockula_prefs │
|
||||
│ ClockulaDatabase v4 │ │ clockula_prefs │
|
||||
└──────────────┬─────────────┘ └─────────┬───────────────┘
|
||||
│ │
|
||||
SQLite preferences_pb
|
||||
@@ -99,7 +99,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `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/db/` | `ClockulaDatabase` (`@Database` v4, `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) |
|
||||
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)`, unchanged since M2; M8 is the milestone that finally calls all of it |
|
||||
@@ -145,10 +145,11 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
|
||||
## 4. The data model
|
||||
|
||||
`ClockulaDatabase` is at **version 3** with five tables. Every column is a
|
||||
`ClockulaDatabase` is at **version 4** with five tables. Every column is a
|
||||
primitive: `Long`, `Int`, `String` or `Boolean`. There are **no Room
|
||||
`TypeConverter`s**. Enums are stored as `Enum.name` in a `TEXT` column,
|
||||
instants and durations as milliseconds, the repeat set as an `INTEGER` bitmask.
|
||||
instants and durations as milliseconds, local dates as epoch days, the repeat
|
||||
set as an `INTEGER` bitmask.
|
||||
That puts the whole entity↔domain translation inside the mappers, which are
|
||||
plain JVM objects that a unit test can feed a corrupt row. A converter would
|
||||
move the same translation into generated code, where an unknown enum name throws
|
||||
@@ -169,6 +170,8 @@ JSON and M10's JSON backup all read the same vocabulary in a diff.
|
||||
| `skip_next_occurrence` | INTEGER | |
|
||||
| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), anything else a content URI |
|
||||
| `delete_after_use` | INTEGER | v3 (M9): a transient alarm the `AlarmClock` contract asked for. Deleted rather than disabled when its cycle closes (§17) |
|
||||
| `scheduled_date` | INTEGER? | v4: a one-shot's date, as `LocalDate.toEpochDay()`. A local date, never an instant, so no zone is baked in (§11, "A date and a pause") |
|
||||
| `pause_start`, `pause_end` | INTEGER? | v4: a repeating alarm's pause, both ends inclusive epoch days. One without the other reads as no pause |
|
||||
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
|
||||
|
||||
Per-alarm settings are nullable *overrides*, never concrete copies of the
|
||||
@@ -389,6 +392,14 @@ table that *has* one, and the migration test fails for a reason nobody enjoys
|
||||
finding. Every row written before M9 therefore reads as the permanent alarm or
|
||||
timer it was.
|
||||
|
||||
**v3 → v4 (#21).** `MIGRATION_3_4` adds three nullable `INTEGER` columns to
|
||||
`alarms`: `scheduled_date`, `pause_start` and `pause_end`, with no default, as
|
||||
the entity declares them, so every alarm written before v4 reads as unscheduled
|
||||
and unpaused. A date is only ever stored on a non-repeating alarm and a pause
|
||||
only on a repeating one; `AlarmMapper` reads the other combination, or an epoch
|
||||
day outside `AlarmDates` (2000-01-01 to 9999-12-31), as absent rather than
|
||||
throwing.
|
||||
|
||||
---
|
||||
|
||||
## 5. The two clocks
|
||||
@@ -751,7 +762,7 @@ and a TalkBack-shaped accessibility click completing the hold challenge.
|
||||
zones with awkward transitions.
|
||||
- **Instrumentation-only** is the half a fake cannot prove: generated SQL,
|
||||
unique indices, real `@Transaction` behaviour, the foreign key cascading, the
|
||||
v1 → v2 and v2 → v3 migrations validated against the exported schema, and the
|
||||
v1 → v2, v2 → v3 and v3 → v4 migrations validated against the exported schema, and the
|
||||
database opening at its current version.
|
||||
- **`ArchitectureRulesTest`** greps the main source set for Room leakage above
|
||||
`data/` and Android imports inside `domain/`, `alarm/AlarmEngine.kt` and
|
||||
@@ -1289,6 +1300,46 @@ one audio stream.
|
||||
|
||||
---
|
||||
|
||||
### A date and a pause
|
||||
|
||||
Two optional fields shape the occurrence sequence without touching the
|
||||
resolver's precedence (#21). Both are **local dates**, so "Thursday the 28th"
|
||||
is the 28th of whatever zone the phone is in when it arrives, and both are
|
||||
applied by `AlarmOccurrences.sequence`, the one place that knows about DST:
|
||||
|
||||
- **`scheduledDate`** (one-shot only) is a *floor*: no candidate on an earlier
|
||||
local date. It is not a filter, so the sequence stays infinite. A date missed
|
||||
while the phone was off leaves the alarm behaving exactly as a missed plain
|
||||
one-shot does, ringing at its next time of day, and the snooze and skip
|
||||
paths, which call `.first` on the sequence, can never run dry. Ringing on the
|
||||
date closes the cycle like any one-shot's: the alarm switches off.
|
||||
- **`pause`** (repeating only) is a *hole*: candidates whose local date falls
|
||||
inside it are dropped. It is jumped rather than walked, so a corrupt pause
|
||||
centuries long costs one step. "Skip next" still works on a paused alarm and
|
||||
skips the first ring after the pause; it stays the quick version of a pause.
|
||||
|
||||
Both clear themselves, and the rule is exact rather than approximate: a date or
|
||||
a pause is cleared once it lies wholly before `firstDateFor(now - fireGrace)`,
|
||||
the earliest local date the sequence looks at. At that point it cannot change a
|
||||
single candidate, so clearing it changes nothing but the row. Measuring from
|
||||
`now - fireGrace` rather than from today is load-bearing: a pause ending at
|
||||
midnight would otherwise be cleared at 00:00 and let its last day's 23:59
|
||||
occurrence ring late through the grace window. The resolver reports
|
||||
`clearScheduledDate` / `clearPause` on every branch, a disabled alarm included,
|
||||
and the engine writes them through `AlarmRepository.edit`, conditional on the
|
||||
stored value still being the one it resolved, so a pause the editor has just
|
||||
picked is never cleared by a resolution of the old one.
|
||||
|
||||
The surfaces never show a stale one in the meantime: `AlarmSchedule` shows a
|
||||
date only while its occurrence is still ahead and a pause only while its last
|
||||
day has not passed. The editor's date picker starts at `AlarmSchedule.earliestDate`,
|
||||
the date of the alarm's next plain occurrence, so neither a past day nor
|
||||
today-after-the-time-has-gone can be picked; moving the time earlier than now
|
||||
on a date set for today drops the date. Picking a repeat day clears the date
|
||||
and clearing the last one clears the pause, as in Google Clock. The
|
||||
`AlarmClock` contract cannot express either field, so `SET_ALARM` never
|
||||
re-uses an alarm that carries one (§17).
|
||||
|
||||
## 12. The app shell
|
||||
|
||||
Four tabs, one host, and one surface that follows whatever is running.
|
||||
@@ -2605,7 +2656,11 @@ the extras can set, so a snooze override the user set by hand does not prevent i
|
||||
Ties go to the **lower id**, the same rule the alarm engine's precedence
|
||||
already uses. A reused alarm is enabled *and* has any pending skip cleared,
|
||||
because leaving one armed would silently eat the occurrence just asked for, and
|
||||
it is never made transient, as the contract's own parenthesis says. A timer is
|
||||
it is never made transient, as the contract's own parenthesis says. An alarm
|
||||
with a scheduled date or a pause is never identical: the extras cannot express
|
||||
either, and re-using it would either keep the caller's alarm quiet until the
|
||||
pause ends or throw away a holiday the user set, so a new alarm is created
|
||||
instead. A timer is
|
||||
identical only if it is **IDLE** with the same duration and label: restarting
|
||||
somebody's running countdown is destructive.
|
||||
|
||||
|
||||
Reference in new issue
Block a user