fix(alarms): keep a ringing card out of the swipe, and Undo off the ViewModel scope

A ringing alarm's card no longer swipes or offers Delete: it leads to
the ring screen, and a swipe must not get round the dismiss challenge.
A drag cancelled before release (a ring screen opening on top) never
deletes. Undo restores on the application scope, since a claimed Undo
has no second chance. Closing a cycle and deleting share one teardown.
This commit is contained in:
makiolaj committed 2026-10-21 20:22:25 +02:00
1 parent ef26d433e1
commit af12b7bb34
8 files changed
+60 -24

No files matched your search

@@ -153,11 +153,7 @@ class AlarmEngine @Inject constructor(
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)
scheduler.cancelAutoSilence()
}
notifier.clearSnoozed(alarmId)
silenceLocked(alarmId, state)
alarms.delete(alarmId)
rescheduleLocked()
return AlarmDeletion.snapshot(alarm, state)
@@ -406,11 +402,7 @@ class AlarmEngine @Inject constructor(
* with its backstop intact.
*/
private suspend fun closeCycleLocked(alarmId: Long, state: AlarmRingState) {
if (state.ringingSince != null) {
ring.stopRinging(alarmId)
scheduler.cancelAutoSilence()
}
notifier.clearSnoozed(alarmId)
silenceLocked(alarmId, state)
val closed = state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)
if (closed != state) states.save(closed)
@@ -419,4 +411,17 @@ class AlarmEngine @Inject constructor(
if (alarm != null && !alarm.repeatDays.isRepeating) disableOrDeleteLocked(alarm)
rescheduleLocked()
}
/**
* Everything audible or visible an alarm's cycle holds: the ring service and
* its backstop if it is the one ringing, and its snooze notification. The
* one teardown, for closing a cycle and for deleting the alarm.
*/
private fun silenceLocked(alarmId: Long, state: AlarmRingState) {
if (state.ringingSince != null) {
ring.stopRinging(alarmId)
scheduler.cancelAutoSilence()
}
notifier.clearSnoozed(alarmId)
}
}
@@ -48,7 +48,9 @@ import java.util.Locale
*
* Swiping it towards the end edge deletes it, and so does the card's "Delete"
* custom action, which is the same verb for a screen reader or switch access,
* where a swipe is not to be had. The list's snackbar offers the Undo.
* where a swipe is not to be had. The list's snackbar offers the Undo. Neither
* is offered while the alarm rings: the card leads to the ring screen then, and
* a swipe must not become a way round its dismiss challenge.
*/
@Composable
internal fun AlarmRow(
@@ -76,16 +78,19 @@ internal fun AlarmRow(
onDelete = onDelete,
shape = RoundedCornerShape(SwipeBackgroundCorner),
modifier = modifier.padding(horizontal = GroupedListInset),
enabled = !row.ringing,
) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction(deleteAction) {
onDelete()
true
},
)
if (!row.ringing) {
customActions = listOf(
CustomAccessibilityAction(deleteAction) {
onDelete()
true
},
)
}
},
onClick = onClick,
color = if (row.enabled) {
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.alarm.DeletedAlarm
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -30,6 +32,8 @@ class AlarmsViewModel @Inject internal constructor(
private val zones: ZoneProvider,
private val source: AlarmListSource,
private val undo: AlarmUndo,
// `@param:` — see AlarmEditorViewModel.
@param:ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() {
val state: StateFlow<AlarmsUiState> = source.rows()
@@ -90,10 +94,14 @@ class AlarmsViewModel @Inject internal constructor(
viewModelScope.launch { engine.delete(alarmId)?.let(undo::offer) }
}
/** The snackbar's Undo. Claimed first, so a double tap restores once. */
/**
* The snackbar's Undo. Claimed first, so a double tap restores once, and
* restored on the application scope: once claimed there is no second
* chance, so a back press that clears this ViewModel must not drop it.
*/
fun onUndoDelete(deleted: DeletedAlarm) {
if (!undo.take(deleted)) return
viewModelScope.launch { engine.restore(deleted) }
appScope.launch { engine.restore(deleted) }
}
/** The snackbar timed out or was swiped away: the delete stands. */
@@ -61,6 +61,7 @@ internal fun SwipeToDelete(
onDelete: () -> Unit,
shape: Shape,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
val currentOnDelete by rememberUpdatedState(onDelete)
@@ -80,7 +81,8 @@ internal fun SwipeToDelete(
Box(
modifier = modifier
.onSizeChanged { width = it.width }
.pointerInput(direction, flingVelocity) {
.pointerInput(direction, flingVelocity, enabled) {
if (!enabled) return@pointerInput
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val velocity = VelocityTracker()
@@ -100,7 +102,7 @@ internal fun SwipeToDelete(
// launched, so the next delta can arrive before it lands.
var position = (travel.value + slopOvershoot).coerceIn(0f, width.toFloat())
var armed = position >= width * DeleteFraction
horizontalDrag(start.id) { change ->
val released = horizontalDrag(start.id) { change ->
velocity.addPointerInputChange(change)
position = (position + change.positionChange().x * direction)
.coerceIn(0f, width.toFloat())
@@ -118,6 +120,9 @@ internal fun SwipeToDelete(
// towards the start is kept however far it had gone.
val towardsEnd = velocity.calculateVelocity().x * direction
val delete = when {
// Cancelled rather than let go (a ring screen opening
// on top, the shade pulled): nothing was decided.
!released -> false
towardsEnd >= flingVelocity -> position > 0f
towardsEnd <= -flingVelocity -> false
else -> armed
@@ -9,6 +9,8 @@ import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -31,6 +33,9 @@ class TimersViewModel @Inject internal constructor(
private val engine: TimerEngine,
private val settings: SettingsPrefs,
source: TimerListSource,
// `@param:` — the qualifier has no explicit @Target, and K2 resolves a bare
// annotation on a constructor `val` to the property instead.
@param:ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() {
/** `WhileSubscribed(5_000)`, seeded [TimersUiState.Loading]. */
@@ -98,10 +103,14 @@ class TimersViewModel @Inject internal constructor(
engine.delete(timerId)?.let { undoSlot.value = it }
}
/** The snackbar's Undo. Claimed first, so a double tap restores once. */
/**
* The snackbar's Undo. Claimed first, so a double tap restores once, and
* restored on the application scope: once claimed there is no second
* chance, so a back press that clears this ViewModel must not drop it.
*/
fun onUndoDelete(timer: Timer) {
if (!undoSlot.compareAndSet(timer, null)) return
launchOnEngine { engine.restore(timer) }
appScope.launch { engine.restore(timer) }
}
/** The snackbar timed out or was swiped away: the delete stands. */
@@ -53,6 +53,7 @@ class AlarmsViewModelTest {
zones = harness.zones,
source = AlarmListSource(harness.engine, harness.wallClock, harness.ticker),
undo = undo,
appScope = this,
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
private fun AlarmsUiState.rows(): List<AlarmRowState> = (this as AlarmsUiState.Loaded).rows
@@ -50,6 +50,7 @@ class TimersViewModelTest {
engine = harness.engine,
settings = harness.settings,
source = TimerListSource(harness.timers, harness.elapsed, harness.wallClock, harness.ticker),
appScope = this,
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
private fun TimersUiState.loaded(): TimersUiState.Loaded = this as TimersUiState.Loaded
+3 -1
View File
@@ -1718,7 +1718,9 @@ be a lie; the user who wants that has the enable switch.
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. Each is followed by a snackbar on the list ("07:00 alarm deleted",
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. Each is followed by a snackbar 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 snackbar does: both only catch the slip noticed in the moment, and a