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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user