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:
commit
d957fdf23d
38 files changed
+1577
-215
No files matched your search
+78
-9
@@ -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
|
||||
|
||||
+4
-3
@@ -247,7 +247,8 @@ line ("in 9h 12m") on each row. Built on `GroupedSurface`/`GroupedRow`.
|
||||
|
||||
Knowingly open: no hand reordering of alarms (the time order answers it, and
|
||||
`alarms` has no `sort_order` column), no multi-select or bulk delete, no undo
|
||||
chip after a delete (traded for a confirmation that names the alarm), no
|
||||
chip after a delete (traded for a confirmation that names the alarm; since
|
||||
replaced by swipe to delete with Undo, #20), no
|
||||
repeat presets, and no per-alarm auto-silence (still M10 at the earliest).
|
||||
Editing the app-wide defaults every override row offers to follow is M10's
|
||||
settings screen. The seven new instrumentation tests compile in the gate but
|
||||
@@ -316,8 +317,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 (since added as
|
||||
an app-wide timer setting, #22), no presets
|
||||
a confirmation, as with alarms; added since, #20), 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
|
||||
|
||||
Reference in new issue
Block a user