Merge branch 'feat/20-swipe-delete-undo' into release/v1.0.0

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt
#	docs/ROADMAP.md
This commit is contained in:
makiolaj committed 2026-10-26 21:54:31 +01:00
commit d957fdf23d
38 files changed
+1577 -215

No files matched your search

+78 -9
View File
@@ -1098,7 +1098,7 @@ wake lock.
| The volume-button setting (#18) while the alarm rings only as a heads-up over another app, or with the screen turned off (the system keeps volume keys from a non-interactive device's windows). A background app sees no key events; the one route, a remote-playback `MediaSession` whose `VolumeProvider` takes the keys, is refused by `MediaSessionRecord.canHandleVolumeKey` on Android 13+ for an app with no MediaRouter2 routing session, loses to whichever app is in front or played last, and shows a cast-volume slider and a phantom media player while it works. AOSP DeskClock and BlackyHawky's Clock handle the keys in their alarm activity only, as `AlarmRingActivity` does | not planned |
| 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 | 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 "stop all" for several expired timers. Each expired timer is acknowledged on its own | 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 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 |
@@ -1909,13 +1909,15 @@ auto-silence backstop are single *global* slots (§11), touched only by
`closeCycleLocked`, and `onAutoSilence` returns early once `ringingSince` is
null. So switching off or deleting a ringing alarm from this screen would clear
its state and leave it **sounding until the 15-minute wake-lock timeout, with
the backstop disarmed by its own guard**. Both ViewModels therefore call
the backstop disarmed by its own guard**. The list therefore calls
```kotlin
if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId)
```
before `setEnabled(id, false)` and before `delete(id)`. It reads storage under
before `setEnabled(id, false)`. Deleting is an engine verb of its own
(`AlarmEngine.delete`, below), which stops a ring itself but, from the list and
the editor, refuses a ringing alarm outright. The check reads storage under
the engine's own mutex, so it cannot race the fire it is asking about, and it is
deliberately **conditional**: dismissing unconditionally would also drop a
merely *snoozed* alarm's pending snooze, which is wrong for a label edit.
@@ -1945,10 +1947,65 @@ a **repeating** alarm. The resolver answers a one-shot-with-skip by disabling th
alarm, and a switch labelled "skip" that silently switched the alarm off would
be a lie; the user who wants that has the enable switch.
Delete lives in the editor behind a confirmation that names the alarm, and there
is deliberately no undo chip: the delete happens on the editor, so an undo chip
would have to live on the list, and an accidentally deleted alarm is discovered
at 07:00 rather than now; the cheap moment to ask is before.
### Delete, and the Undo
An alarm is deleted by swiping its card towards the end edge, by the card's
**Delete** custom accessibility action (the same verb for TalkBack and switch
access, where there is no swipe), or from the editor's top bar. None of them
asks first. A *ringing* card offers neither the swipe nor the action: it leads
to the ring screen, and a swipe must not become a way round its dismiss
challenge. The engine re-checks under its lock (`delete(id, unlessRinging = true)`),
so a swipe released just as the alarm fires, before the list has caught up,
deletes nothing and the card slides back. The editor's Delete passes the same
flag: with its confirmation gone, one tap there would otherwise do what the
swipe is kept from doing, so a refused tap opens the ring screen instead, where the alarm is dealt with. Each is followed by a chip on the list ("07:00 alarm deleted",
**Undo**), as in Google Clock. v1 asked before instead, on the argument that a
mistaken delete is found at 07:00 rather than now. But a dialog catches no more
than the chip does: both only catch the slip noticed in the moment, and a
confirmation the user taps through out of habit catches less. The Undo does it
without taxing every delete the user meant, so the editor's dialog went too
rather than leaving two delete paths with different safety nets.
`AlarmEngine.delete(id)` does the work under the engine's lock: it stops a
ringing alarm's service and auto-silence backstop (the global slots of §11),
clears a snoozed alarm's notification, deletes the row (the ring state
cascades) and re-resolves the slot. It returns a `DeletedAlarm`, the row and
its ring state as `AlarmDeletion.snapshot` decided, and `AlarmEngine.restore`
puts that back **under the same id** (`alarms.id` is `AUTOINCREMENT`, so no new
alarm can have taken it) and re-resolves, which is what re-registers it with
AlarmManager. The snapshot keeps every setting and the ring state as it was, so a
snoozed alarm comes back snoozed, notification included, and the handled-occurrence
watermark still stops a just-closed occurrence ringing twice. A **ringing**
alarm is the exception: deleting it was a dismissal in all but name, so it comes
back as a dismissal would have left it (cycle closed, a one-shot switched off)
and never rings again, and a ringing transient alarm, which that dismissal would
have deleted anyway, offers no Undo at all. A snooze that lapsed while the alarm
was gone is closed by the resolver on restore, and gets no notification. An
alarm deleted just before its time and restored just after it rings at once,
since the occurrence is still inside the fire grace window: Undo means "as if it
had not been deleted", and that alarm would have rung.
The pending deletion lives in `AlarmUndo`, a singleton, not in either
ViewModel. The editor is popped the moment its alarm is gone, so its ViewModel
is cleared before any chip could be answered; the list underneath reads the
deletion from the singleton and shows the chip (`UndoChipBand`). It
is cleared when the chip is answered or times out, when a newer deletion
replaces it, and when the screen is left with the chip unanswered (another
tab, Settings, the app closed): an Undo held back and offered later would
restore a snapshot from long ago, out of any context, and over a backup that
was restored meanwhile. A rotation is the one exception and shows the chip
again. A deletion offered while no list was on screen (the app sent home the
instant the editor's Delete was tapped) is dropped unshown if it is more than
30 seconds old by the time the list is back. Both lists share this as
`UndoSlot`. It is memory only, so process death drops it too.
The swipe is hand-rolled (`ui/common/SwipeToDelete`) rather than M3's
`SwipeToDismissBox`, because the tabs are pages of a `HorizontalPager` and the
box claims a horizontal drag in both directions, even one it has no anchor for.
Only the start-to-end drag is claimed; the end-to-start one is left unconsumed,
so a swipe across a card still changes tab. Start-to-end is also the pager's
"previous tab" direction, which from Alarms, the first tab, goes nowhere, so the
two gestures never want the same drag.
---
@@ -1984,6 +2041,17 @@ already met once, and the cheapest way not to have it again is to make the wrong
call impossible to write. **Configuration** edits (`rename`, `setDuration`,
`setRingtoneUri`) stay on `TimerRepository` and the editor calls them directly.
A card's corner delete asks nothing first, and the list's chip offers an
**Undo**. `TimerEngine.delete` returns the row as `TimerDeletion.snapshot`
decided, and `TimerEngine.restore` puts it back under its own id and place and
resyncs. A running timer comes back with its anchors, so it has kept counting
while it was gone, and one whose end passed in the meantime rings, as it would
have had it never been deleted. A timer that had already finished comes back
reset: deleting it silenced it, which is a dismissal, so it does not ring a
second time (a finished transient timer offers no Undo). The pending row is
`TimersViewModel` state, since both the delete and the chip are on the
list; the editor's delete keeps its confirmation.
`TimerRepository` deliberately has **no generic `edit(id, transform)`** the way
`AlarmRepository` does: a timer row carries three derived anchors whose
consistency is the whole of §5, and a caller handed a `(Timer) -> Timer` can
@@ -2791,8 +2859,9 @@ an id the list does not hold, and always a permutation of its input; a move that
changes nothing writes nothing.
Remove is a long-press plus a confirmation that names the city, with
deliberately **no undo chip**. That is the same trade M5 and M6 made: the cheap
moment to ask is before. The pending id is `rememberSaveable`, so a rotation
deliberately **no undo chip**. That was the trade M5 and M6 made too, and the
alarm and timer lists have since moved to an Undo (§13, §14); removing a city
still asks first. The pending id is `rememberSaveable`, so a rotation
mid-confirmation does not lose the question.
A row whose zone the device's tzdata no longer knows shows **no time, no offset