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 446975b..561465d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -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) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt index 7e3752b..4b300d7 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt index 00f33c4..09b14a4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt @@ -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 = 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. */ 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 9a858d5..9ef68e2 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 @@ -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 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 3a5e142..3e67c16 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 @@ -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. */ diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt index 9abed55..9ca7e8b 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt @@ -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 = (this as AlarmsUiState.Loaded).rows diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt index bc90f75..0a6c0b6 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fd381a6..fd46477 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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