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:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user