From ef26d433e175b972cb92e35cb538627261dd6f00 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 20:13:09 +0200 Subject: [PATCH] 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. --- .../clockula/alarm/AlarmEngine.kt | 26 +++++++--- .../clockula/data/alarms/AlarmRepository.kt | 6 +-- .../clockula/data/timers/TimerRepository.kt | 4 +- .../clockula/timer/TimerEngine.kt | 25 +++++---- .../ui/alarms/AlarmEditorViewModel.kt | 3 ++ .../clockula/ui/alarms/AlarmUndo.kt | 4 +- .../clockula/ui/alarms/AlarmsScreen.kt | 29 ++++------- .../clockula/ui/common/SwipeToDelete.kt | 38 +++++++++++--- .../clockula/ui/common/UndoSnackbar.kt | 51 +++++++++++++++++++ .../clockula/ui/timers/TimersScreen.kt | 26 +++------- .../clockula/ui/timers/TimersViewModel.kt | 4 +- docs/ARCHITECTURE.md | 15 +++--- 12 files changed, 157 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSnackbar.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt index 82317c3..446975b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt index 05b92d7..143e920 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt index 515a0e7..7199a8d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt index 71f8155..feafcf3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt @@ -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 + } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt index 7605bf1..c7b9d64 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt index 63e04e8..561fbfd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt index d630a79..40aad3b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -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), diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/SwipeToDelete.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/SwipeToDelete.kt index ec4f8db..9a858d5 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/SwipeToDelete.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/SwipeToDelete.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSnackbar.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSnackbar.kt new file mode 100644 index 0000000..a3f3881 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSnackbar.kt @@ -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 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) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt index 36b1db0..7a9e6a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt index 3d07d9f..3a5e142 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt @@ -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 = undoSlot.asStateFlow() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8eec71d..fd381a6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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