feat(timers): give timers their own vibration and gradual volume

Timers vibrated by the alarm vibration default and never ramped. Both are
now the timers' own app-wide settings, next to the default timer sound in
Settings > Timers: a Vibrate switch and the same Gradual volume picker
alarms use. Timer.resolveSettings reads them instead of the alarm default
and the old TimerRing.RAMP_SECONDS constant.

A fresh install starts with timer vibration off, as Google Clock does, and
no ramp. An existing install keeps what its timers did: a one-shot DataStore
migration writes the new key from the alarm vibration default before the
first read, so nobody's kitchen timer silently stops buzzing on update. A
fresh install is told apart by an empty store (anything that has run once
has written its boot id); the migration writes the default there too, so it
never runs twice.

Backups carry both new fields. A file without them restores the alarm
vibration default as the timers' (what that install's timers did) and a
flat ring.

Silent plus vibration off still signals: Silent gives the player no
sources, and its onSilenced callback starts the vibrator whatever the
setting says. The settings row says so.

Fixes #22
This commit is contained in:
makiolaj committed 2026-10-21 18:49:45 +02:00
1 parent 3f431a7f32
commit fbc3a192bf
21 files changed
+375 -38

No files matched your search

+24 -10
View File
@@ -595,7 +595,7 @@ Four slices:
| Slice | Keys | Owner |
|---|---|---|
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs`, the family's shared key names |
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_timer_vibrate`, `default_timer_volume_ramp_seconds` (#22), `default_dismiss_challenge`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
@@ -1062,7 +1062,7 @@ M10 adds no `<uses-permission>`.
| 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 (add/remove the quick-start durations themselves). M10's settings screen edits `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 |
| A dismiss challenge or a snooze for a timer. (A timer that ramps its volume landed with #22, as an app-wide timer setting.) | 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 |
| A Lap button on the live pill. The pill has two controls by design, and a third would rewrite a contract M4 asserted | not planned for v1 |
@@ -1282,8 +1282,8 @@ loop, the ramp stepper, the looped waveform, `AudioSourcePolicy`,
and are used verbatim by both, so "silent" still means "vibration only" for a
timer too. What the alarm **keeps to itself** is the full-screen intent, the
ring activity, the dismiss challenge and the snooze; what the timer
parameterises is its own ringtone, a ramp of **zero seconds** (a timer is set by
someone awake, and a gentle start is just a quiet start) and its own
parameterises is its own ringtone, its own vibration and ramp settings (#22;
the ramp is **zero seconds** unless the user asks for one) and its own
auto-silence constant. §14 has the rest, including which of the two wins the
one audio stream.
@@ -1898,11 +1898,24 @@ finished, and the user coming back must be able to see that and add a minute
to it. Auto-silence is decided **before** suppression, so an alarm ringing over
a window that has run out cannot hand it a fresh one.
`TimerRing.RAMP_SECONDS` is **0**. The volume ramp exists so an alarm does not
A timer's ramp is `ClockDefaults.timerVolumeRampSeconds`, **0** unless the
user picks one in Settings > Timers (#22; it was the constant
`TimerRing.RAMP_SECONDS` before). The volume ramp exists so an alarm does not
jolt a sleeping person awake; a timer is set by someone awake who wants to know
*now*, and for them a gentle start is a quiet start. `VolumeRamp.levelAt(_, 0s)`
already returns `1f`, so "parameterised" means one different argument to the
same player.
*now*, and for most of them a gentle start is a quiet start, so the default
stays flat. `VolumeRamp.levelAt(_, 0s)` already returns `1f`, so
"parameterised" means one different argument to the same player.
Vibration is `ClockDefaults.timerVibrate`, never the alarms' `vibrate`: an
alarm that should buzz the bed and a kitchen timer that should only beep are
different questions. A fresh install has it **off**. An install from before
the setting existed had its timers vibrate exactly when the alarm default did,
so `TimerVibrateMigration` (a DataStore migration, run before the first read)
writes that value once rather than silently changing what its timers do; it
tells a fresh install by an empty store, since anything that has run has
written its boot id. Silent with vibration off is still not silence:
`AudioSourcePolicy` gives Silent no sources, and the player's `onSilenced`
starts the vibrator regardless of the setting.
### The notification: one channel, one id, alert once
@@ -2060,8 +2073,9 @@ ever read it, so it was removed. A backup file that still carries the key
imports with it ignored.)
A timer has exactly **one** per-setting override, its ringtone, because `timers`
has exactly one nullable settings column. Vibrate and the (zero) ramp come from
`ClockDefaults`. The picker is M5's, moved to `ui/common/`, so the row inherits
has exactly one nullable settings column. Vibrate and the ramp come from
`ClockDefaults`' timer-only `timerVibrate` and `timerVolumeRampSeconds` (#22),
not the alarm defaults. The picker is M5's, moved to `ui/common/`, so the row inherits
all of its behaviour for free: "App default" naming what it resolves to, Silent
as an explicit sentinel that forces vibration, a SAF-picked file that is still
stored when its grant cannot be made persistent, and an unreadable sound
+6
View File
@@ -42,6 +42,10 @@ written here.
are clamped at parse time to the same ranges as the defaults they
override, and a timer's `duration_millis` to the setup range (1 second to
24 hours). Room has no clamp of its own, so this is where it happens.
- A missing `default_timer_vibrate` (a file from before timers had their own
vibration setting) restores `default_vibrate` in its place: that is what
the exporting install's timers vibrated by. A missing
`default_timer_volume_ramp_seconds` restores 0, the ring timers had then.
- A missing `timer_presets` keeps the built-in presets. An empty list is a
user who removed them all, and is imported as such.
- An enum field with a name this version does not recognise (a `dismiss_
@@ -134,6 +138,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"default_volume_ramp_seconds": 15,
"default_alarm_ringtone_uri": null,
"default_timer_ringtone_uri": null,
"default_timer_vibrate": false,
"default_timer_volume_ramp_seconds": 0,
"default_dismiss_challenge": "NONE",
"home_zone_id": null,
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000]
+2 -1
View File
@@ -316,7 +316,8 @@ Elapsed-realtime anchored.
per-timer vibrate, volume-ramp or dismiss-challenge overrides (`timers` has
one nullable settings column and four more would be a schema change), no
"stop all" when several timers are expired, no undo after a delete (traded for
a confirmation, as with alarms), no ramp on a timer's ring, no presets
a confirmation, as with alarms), no ramp on a timer's ring (since added as
an app-wide timer setting, #22), no presets
management screen and no editing of `default_timer_duration_millis` or
`default_timer_ringtone_uri` (M10's settings screen). `TimerSnapshot.anchorIsStale`
is still not surfaced in the UI. Of `AlarmClock`'s intent contract only the