fix(alarms): let an unanswered Undo lapse, and make delete and restore atomic

A snackbar the screen leaves unanswered (another tab, Settings, the app
closing) now counts as expired, except across a rotation. Held back, it
could restore a snapshot from hours ago, ring a long-gone timer, or
land on top of a backup restored in the meantime. One shared
UndoSnackbarEffect does this for alarms and timers.

The engines' delete and restore run non-cancellable, so a caller
cancelled half-way cannot leave a restored alarm unscheduled. The
editor writes the label still in flight before it deletes, so Undo
brings back what was typed.

The swipe counts the touch-slop overshoot, keeps a card flung back
towards the start, slides a card home if its row is still there a
second after the delete, and no longer recomposes on every drag frame.
This commit is contained in:
2026-10-21 20:13:09 +02:00
parent 2feed2ee35
commit ef26d433e1
12 changed files with 157 additions and 74 deletions
@@ -23,6 +23,7 @@ import de.jeanlucmakiola.clockula.domain.resolveSettings
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.system.RebootRepair
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -31,6 +32,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import javax.inject.Inject
@@ -141,10 +143,15 @@ class AlarmEngine @Inject constructor(
* a ringing alarm's service and auto-silence backstop are global slots
* only the engine may touch, and a snoozed alarm's notification would
* otherwise outlive the row it belongs to. The ring state goes with the row
* (the foreign key cascades).
* (the foreign key cascades). Non-cancellable, like [restore]: a caller
* cancelled half-way must not leave the slot registered for a deleted row.
*/
suspend fun delete(alarmId: Long): DeletedAlarm? = lock.withLock {
val alarm = alarms.find(alarmId) ?: return@withLock null
withContext(NonCancellable) { deleteLocked(alarmId) }
}
private suspend fun deleteLocked(alarmId: Long): DeletedAlarm? {
val alarm = alarms.find(alarmId) ?: return null
val state = states.state(alarmId)
if (state.ringingSince != null) {
ring.stopRinging(alarmId)
@@ -153,24 +160,31 @@ class AlarmEngine @Inject constructor(
notifier.clearSnoozed(alarmId)
alarms.delete(alarmId)
rescheduleLocked()
AlarmDeletion.snapshot(alarm, state)
return AlarmDeletion.snapshot(alarm, state)
}
/**
* The Undo: the row back under its own id, its ring state with it, and the
* slot re-resolved, which is what re-registers it with AlarmManager. False
* when the id has been taken since (a backup restore in between).
* when the id is taken, which writes nothing.
*
* A snooze comes back with its notification, but only if the resolver
* still holds it pending once the slot is re-resolved: one that lapsed
* while the alarm was gone is closed there, like any other.
*
* Non-cancellable: a row restored but never rescheduled would be an alarm
* on the list that AlarmManager does not know about.
*/
suspend fun restore(deleted: DeletedAlarm): Boolean = lock.withLock {
if (!alarms.restore(deleted.alarm)) return@withLock false
withContext(NonCancellable) { restoreLocked(deleted) }
}
private suspend fun restoreLocked(deleted: DeletedAlarm): Boolean {
if (!alarms.restore(deleted.alarm)) return false
if (deleted.state != AlarmRingState.initial(deleted.alarm.id)) states.save(deleted.state)
rescheduleLocked()
states.state(deleted.alarm.id).snoozedUntil?.let { until -> notifier.showSnoozed(deleted.alarm, until) }
true
return true
}
/** Boot repair (timers + stopwatch), then resume any interrupted ring, then reschedule. */
@@ -15,9 +15,9 @@ interface AlarmRepository {
/**
* Puts a deleted alarm back under its own id, timestamps and all — the
* Undo after a delete. False when that id is taken (a backup restore
* landed in between), in which case nothing is written. `alarms.id` is
* `AUTOINCREMENT`, so a new alarm can never have claimed it.
* Undo after a delete. `alarms.id` is `AUTOINCREMENT`, so no new alarm can
* have claimed it; should the id be taken all the same, nothing is written
* and the answer is false, rather than a crash or an overwrite.
*/
suspend fun restore(alarm: Alarm): Boolean
suspend fun setEnabled(id: Long, enabled: Boolean)
@@ -15,8 +15,8 @@ interface TimerRepository {
/**
* Puts a deleted timer back under its own id, anchors and place in the
* list — the Undo after a delete. False when that id is taken (a backup
* restore landed in between), in which case nothing is written.
* list — the Undo after a delete. False, writing nothing, should that id
* be taken (`timers.id` is `AUTOINCREMENT`, so no new timer can have it).
*/
suspend fun restore(timer: Timer): Boolean
@@ -16,9 +16,11 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
import de.jeanlucmakiola.clockula.domain.timer.TimerRingPolicy
import de.jeanlucmakiola.clockula.system.RebootRepair
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import kotlin.time.Instant
import javax.inject.Inject
@@ -185,24 +187,29 @@ class TimerEngine @Inject constructor(
* path deliberately: the wrong call should be impossible to write.
*
* Returns what an Undo would put back ([TimerDeletion.snapshot]), or null
* when there is nothing to put back.
* when there is nothing to put back. Non-cancellable, like [restore]: a
* caller cancelled half-way must not leave the slot or the service behind.
*/
suspend fun delete(timerId: Long): Timer? = lock.withLock {
val timer = timers.find(timerId)
timers.delete(timerId)
resyncLocked()
timer?.let { TimerDeletion.snapshot(it, elapsed.elapsedRealtime(), wall.now()) }
withContext(NonCancellable) {
val timer = timers.find(timerId)
timers.delete(timerId)
resyncLocked()
timer?.let { TimerDeletion.snapshot(it, elapsed.elapsedRealtime(), wall.now()) }
}
}
/**
* The Undo: the row back under its own id, then the same resync every verb
* ends with, which hands the expiry slot and the service the restored timer.
* False when the id has been taken since (a backup restore in between).
* False when the id is taken, which writes nothing.
*/
suspend fun restore(timer: Timer): Boolean = lock.withLock {
if (!timers.restore(timer)) return@withLock false
resyncLocked()
true
withContext(NonCancellable) {
if (!timers.restore(timer)) return@withContext false
resyncLocked()
true
}
}
/**
@@ -218,6 +218,9 @@ class AlarmEditorViewModel @Inject internal constructor(
// this ViewModel, and a delete cut off half-way would leave the slot
// registered for an alarm that is no longer there.
appScope.launch {
// The label still in flight goes in first, so the Undo brings back
// what the user typed rather than what had been written so far.
labelWrites.value?.let { text -> alarms.edit(alarmId) { it.copy(label = text) } }
engine.delete(alarmId)?.let(undo::offer)
_state.value = AlarmEditorUiState.Gone
}
@@ -14,8 +14,8 @@ import javax.inject.Singleton
* are not the place that offers the Undo. The editor is popped the moment its
* alarm is gone, so its ViewModel is cleared before any snackbar could be
* answered; the list underneath shows the snackbar instead, and reads the
* deletion from here. Held until the snackbar is answered or times out, so a
* rotation or a trip to another tab re-shows it rather than losing it.
* deletion from here. Held until the snackbar is answered, times out or is
* left unanswered ([de.jeanlucmakiola.clockula.ui.common.UndoSnackbarEffect]).
*
* Memory only, deliberately: an Undo that survived process death would be
* offered with no snackbar anyone remembers.
@@ -15,10 +15,8 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -31,6 +29,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.common.UndoSnackbarEffect
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
@@ -69,7 +68,6 @@ fun AlarmsScreen(
val snackbar = remember { SnackbarHostState() }
val formatTime = rememberAlarmTimeFormatter()
val undoMessage = pendingUndo?.let { stringResource(R.string.alarm_deleted, formatTime(it.alarm.time)) }
val undoLabel = stringResource(R.string.action_undo)
LaunchedEffect(viewModel) {
// The FAB creates the alarm and the editor edits a persisted row, so
@@ -77,22 +75,15 @@ fun AlarmsScreen(
viewModel.openEditor.collectLatest(onCreateAlarm)
}
// Keyed on the deletion, so a newer one replaces the snackbar on screen
// (and the older delete stands). The deletion lives in a singleton, not
// here: one made in the editor arrives after the editor has gone, and one
// whose snackbar a rotation or another tab interrupted is shown again.
LaunchedEffect(pendingUndo) {
val deleted = pendingUndo ?: return@LaunchedEffect
val result = snackbar.showSnackbar(
message = undoMessage.orEmpty(),
actionLabel = undoLabel,
duration = SnackbarDuration.Long,
)
when (result) {
SnackbarResult.ActionPerformed -> viewModel.onUndoDelete(deleted)
SnackbarResult.Dismissed -> viewModel.onUndoExpired(deleted)
}
}
// The deletion lives in a singleton, not here: one made in the editor
// arrives after the editor has gone.
UndoSnackbarEffect(
pending = pendingUndo,
host = snackbar,
message = undoMessage.orEmpty(),
onUndo = viewModel::onUndoDelete,
onExpired = viewModel::onUndoExpired,
)
CollapsingScaffold(
title = stringResource(R.string.tab_alarms),
@@ -14,6 +14,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
@@ -36,7 +37,9 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
/**
* A card the user can swipe away towards the **end** edge to delete.
@@ -71,6 +74,8 @@ internal fun SwipeToDelete(
// How far the card has travelled towards the end edge: 0 at rest, never negative.
val travel = remember { Animatable(0f) }
var width by remember { mutableIntStateOf(0) }
// Derived, so a drag recomposes this once as the red appears, not per frame.
val revealed by remember { derivedStateOf { travel.value > 0f } }
Box(
modifier = modifier
@@ -80,15 +85,20 @@ internal fun SwipeToDelete(
val down = awaitFirstDown(requireUnconsumed = false)
val velocity = VelocityTracker()
velocity.addPointerInputChange(down)
var slopOvershoot = 0f
val start = awaitHorizontalTouchSlopOrCancellation(down.id) { change, overSlop ->
// Unconsumed, the detection keeps waiting and the pager
// gets its chance at the same drag.
if (overSlop * direction > 0f) change.consume()
if (overSlop * direction > 0f) {
change.consume()
slopOvershoot = overSlop * direction
}
} ?: return@awaitEachGesture
velocity.addPointerInputChange(start)
// Accumulated here, not read back from [travel]: a snap is
// launched, so the next delta can arrive before it lands.
var position = travel.value
var position = (travel.value + slopOvershoot).coerceIn(0f, width.toFloat())
var armed = position >= width * DeleteFraction
horizontalDrag(start.id) { change ->
velocity.addPointerInputChange(change)
@@ -104,11 +114,24 @@ internal fun SwipeToDelete(
change.consume()
}
val flung = velocity.calculateVelocity().x * direction >= flingVelocity
// A fling decides on its own, either way: a card thrown back
// towards the start is kept however far it had gone.
val towardsEnd = velocity.calculateVelocity().x * direction
val delete = when {
towardsEnd >= flingVelocity -> position > 0f
towardsEnd <= -flingVelocity -> false
else -> armed
}
scope.launch {
if (width > 0 && (armed || (flung && position > 0f))) {
if (width > 0 && delete) {
travel.animateTo(width.toFloat(), settleSpec)
currentOnDelete()
// Normally the row has left the list long before this.
// If it is still here (the delete found nothing, or an
// Undo brought the same item back), it slides home
// rather than staying off-screen over the red.
delay(RestoreAfter)
travel.animateTo(0f, settleSpec)
} else {
travel.animateTo(0f, settleSpec)
}
@@ -116,7 +139,7 @@ internal fun SwipeToDelete(
}
},
) {
if (travel.value > 0f) {
if (revealed) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
@@ -143,5 +166,8 @@ internal fun SwipeToDelete(
/** Past this share of the card's width, letting go deletes. */
private const val DeleteFraction = 0.4f
/** A fling at least this fast towards the end deletes, however short the drag. */
/** A fling at least this fast deletes (towards the end) or keeps (back), however far the drag. */
private val FlingVelocity = 800.dp
/** How long a deleted card waits to have left the list before it slides back. */
private val RestoreAfter = 1.seconds
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.clockula.ui.common
import androidx.activity.compose.LocalActivity
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.clockula.R
import kotlinx.coroutines.CancellationException
/**
* Shows [pending]'s "deleted · Undo" snackbar on [host] and reports its one
* answer: [onUndo] for the action, [onExpired] for anything else.
*
* Keyed on the deletion, so a newer one replaces the snackbar on screen and the
* older delete stands. A snackbar the screen *leaves* unanswered (another tab,
* a pushed screen such as Settings, the activity finishing) counts as expired:
* an Undo held back and offered again later would restore a snapshot from
* minutes or hours ago, out of any context. A rotation is the one exception —
* the activity comes straight back and shows the snackbar again.
*/
@Composable
internal fun <T : Any> UndoSnackbarEffect(
pending: T?,
host: SnackbarHostState,
message: String,
onUndo: (T) -> Unit,
onExpired: (T) -> Unit,
) {
val activity = LocalActivity.current
val undoLabel = stringResource(R.string.action_undo)
val currentOnUndo by rememberUpdatedState(onUndo)
val currentOnExpired by rememberUpdatedState(onExpired)
LaunchedEffect(pending) {
val deleted = pending ?: return@LaunchedEffect
val result = try {
host.showSnackbar(message = message, actionLabel = undoLabel, duration = SnackbarDuration.Long)
} catch (cancelled: CancellationException) {
if (activity?.isChangingConfigurations != true) currentOnExpired(deleted)
throw cancelled
}
when (result) {
SnackbarResult.ActionPerformed -> currentOnUndo(deleted)
SnackbarResult.Dismissed -> currentOnExpired(deleted)
}
}
}
@@ -12,12 +12,9 @@ import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
@@ -30,6 +27,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
import de.jeanlucmakiola.clockula.ui.common.UndoSnackbarEffect
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.identity.animateItemMotion
@@ -59,22 +57,14 @@ fun TimersScreen(
val pendingUndo by viewModel.pendingUndo.collectAsStateWithLifecycle()
val snackbar = remember { SnackbarHostState() }
val undoMessage = stringResource(R.string.timer_deleted)
val undoLabel = stringResource(R.string.action_undo)
// Keyed on the deletion, so a newer one replaces the snackbar on screen and
// the older delete stands.
LaunchedEffect(pendingUndo) {
val deleted = pendingUndo ?: return@LaunchedEffect
val result = snackbar.showSnackbar(
message = undoMessage,
actionLabel = undoLabel,
duration = SnackbarDuration.Long,
)
when (result) {
SnackbarResult.ActionPerformed -> viewModel.onUndoDelete(deleted)
SnackbarResult.Dismissed -> viewModel.onUndoExpired(deleted)
}
}
UndoSnackbarEffect(
pending = pendingUndo,
host = snackbar,
message = undoMessage,
onUndo = viewModel::onUndoDelete,
onExpired = viewModel::onUndoExpired,
)
val loaded = state as? TimersUiState.Loaded
val rows = loaded?.rows
@@ -85,8 +85,8 @@ class TimersViewModel @Inject internal constructor(
/**
* The deleted timer the snackbar offers to bring back. Null when none. Held
* here, not in the screen, so a rotation or a trip to another tab re-shows
* the snackbar rather than losing the Undo; a newer delete replaces it.
* here, not in the screen, so a rotation re-shows the snackbar rather than
* losing the Undo; a newer delete replaces it.
*/
val pendingUndo: StateFlow<Timer?> = undoSlot.asStateFlow()
+8 -7
View File
@@ -1740,17 +1740,18 @@ 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 id
that a backup restore claimed in the meantime makes the restore write nothing.
was gone is closed by the resolver on restore, and gets no notification.
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 snackbar could be answered; the list underneath reads the
deletion from the singleton and shows the snackbar. It is cleared only when the
snackbar is answered or times out, or a newer deletion replaces it, so a
rotation or a trip to another tab re-shows the snackbar rather than losing the
Undo. It is memory only: an Undo that outlived process death would be offered
with no snackbar anyone remembers.
deletion from the singleton and shows the snackbar (`UndoSnackbarEffect`). It
is cleared when the snackbar is answered or times out, when a newer deletion
replaces it, and when the screen is left with the snackbar 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 snackbar
again. 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