fix(alarms): offer the delete Undo as the family's SnackChip

The alarm and timer lists show the Undo in floret-kit's SnackChip beside the FAB instead of the stock M3 snackbar, as Agendula and Calendula do.
This commit is contained in:
2026-10-26 21:51:38 +01:00
parent ba1172b822
commit c2a583fda7
12 changed files with 333 additions and 257 deletions
@@ -48,7 +48,7 @@ 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. Neither
* where a swipe is not to be had. The list's chip 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.
*/
@@ -11,10 +11,10 @@ import javax.inject.Singleton
*
* A singleton rather than ViewModel state, because the two places that delete
* 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, times out or is
* left unanswered ([de.jeanlucmakiola.clockula.ui.common.UndoSnackbarEffect]).
* alarm is gone, so its ViewModel is cleared before any chip could be
* answered; the list underneath shows the chip instead, and reads the
* deletion from here. Held until the chip is answered, times out or is
* left unanswered ([de.jeanlucmakiola.clockula.ui.common.UndoChipBand]).
*/
@Singleton
class AlarmUndo @Inject constructor(clock: ElapsedRealtimeClock) : UndoSlot<DeletedAlarm>(clock)
@@ -3,9 +3,15 @@ package de.jeanlucmakiola.clockula.ui.alarms
import android.content.Intent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
@@ -15,13 +21,11 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -29,7 +33,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.UndoChipBand
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
@@ -65,7 +69,6 @@ fun AlarmsScreen(
val pillInset = LocalLivePillInset.current
val addAlarm = stringResource(R.string.alarms_add)
val pendingUndo by viewModel.pendingUndo.collectAsStateWithLifecycle()
val snackbar = remember { SnackbarHostState() }
val formatTime = rememberAlarmTimeFormatter()
val undoMessage = pendingUndo?.let { stringResource(R.string.alarm_deleted, formatTime(it.alarm.time)) }
@@ -75,77 +78,82 @@ fun AlarmsScreen(
viewModel.openEditor.collectLatest(onCreateAlarm)
}
// 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,
isStale = viewModel::isUndoStale,
onShown = viewModel::onUndoShown,
)
CollapsingScaffold(
title = stringResource(R.string.tab_alarms),
modifier = modifier,
scrollable = false,
snackbarHost = { SnackbarHost(snackbar) },
actions = {
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = viewModel::onAddAlarm,
// The pill is bottom-centre and the FAB bottom-end, so on a
// narrow phone they would otherwise overlap (M5 D6).
modifier = Modifier.padding(bottom = pillInset),
) {
Icon(Icons.Rounded.Add, contentDescription = addAlarm)
}
},
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(
top = AlarmsDefaults.ListBottomPadding,
bottom = AlarmsDefaults.ListBottomPadding + AlarmsDefaults.FabBand + pillInset,
),
Box(modifier = modifier.fillMaxSize()) {
CollapsingScaffold(
title = stringResource(R.string.tab_alarms),
scrollable = false,
actions = {
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = viewModel::onAddAlarm,
// The pill is bottom-centre and the FAB bottom-end, so on a
// narrow phone they would otherwise overlap (M5 D6).
modifier = Modifier.padding(bottom = pillInset),
) {
Icon(Icons.Rounded.Add, contentDescription = addAlarm)
}
},
) {
if (rows != null && rows.isEmpty()) {
item { AlarmsEmptyState() }
}
items(rows.orEmpty(), key = { row -> row.id }) { row ->
AlarmRow(
row = row,
// A ringing alarm's row leads back to its ring screen, the
// way out if its notification was swiped away.
onClick = {
if (row.ringing) {
context.startActivity(
Intent(context, AlarmRingActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
} else {
onEditAlarm(row.id)
}
},
onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) },
// No confirmation: the snackbar's Undo is the safety net.
onDelete = { viewModel.onDelete(row.id) },
// An alarm whose time changed slides to its new place
// instead of teleporting.
modifier = animateItemMotion(),
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(
top = AlarmsDefaults.ListBottomPadding,
bottom = AlarmsDefaults.ListBottomPadding + AlarmsDefaults.FabBand + pillInset,
),
) {
if (rows != null && rows.isEmpty()) {
item { AlarmsEmptyState() }
}
items(rows.orEmpty(), key = { row -> row.id }) { row ->
AlarmRow(
row = row,
// A ringing alarm's row leads back to its ring screen, the
// way out if its notification was swiped away.
onClick = {
if (row.ringing) {
context.startActivity(
Intent(context, AlarmRingActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
} else {
onEditAlarm(row.id)
}
},
onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) },
// No confirmation: the chip's Undo is the safety net.
onDelete = { viewModel.onDelete(row.id) },
// An alarm whose time changed slides to its new place
// instead of teleporting.
modifier = animateItemMotion(),
)
}
}
}
// The deletion lives in a singleton, not here: one made in the editor
// arrives after the editor has gone. Beside the FAB, not stacked above it
// as a snackbar would be: the band is the FAB's own, measured from the
// same insets the scaffold gives it.
UndoChipBand(
pending = pendingUndo,
message = undoMessage.orEmpty(),
onUndo = viewModel::onUndoDelete,
onExpired = viewModel::onUndoExpired,
isStale = viewModel::isUndoStale,
onShown = viewModel::onUndoShown,
endClearance = AlarmsDefaults.FabBand,
modifier = Modifier
.align(Alignment.BottomStart)
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom))
.padding(bottom = pillInset),
)
}
// "Which alarm should I dismiss?" — asked only when a search named more
@@ -82,11 +82,11 @@ class AlarmsViewModel @Inject internal constructor(
viewModelScope.launch { engine.dismissUpcoming(alarmId) }
}
/** The deletion the list's snackbar offers to undo, from the swipe or the editor. Null when none. */
/** The deletion the list's chip offers to undo, from the swipe or the editor. Null when none. */
val pendingUndo: StateFlow<DeletedAlarm?> = undo.pending
/**
* The swipe's delete. No confirmation: the snackbar's Undo is the safety
* The swipe's delete. No confirmation: the chip's Undo is the safety
* net, and it catches the same slip a dialog would without taxing every
* delete the user meant.
*/
@@ -99,7 +99,7 @@ class AlarmsViewModel @Inject internal constructor(
}
/**
* The snackbar's Undo. Claimed first, so a double tap restores once, and
* The chip'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.
*/
@@ -108,15 +108,15 @@ class AlarmsViewModel @Inject internal constructor(
appScope.launch { engine.restore(deleted) }
}
/** The snackbar timed out or was swiped away: the delete stands. */
/** The chip timed out: the delete stands. */
fun onUndoExpired(deleted: DeletedAlarm) {
undo.take(deleted)
}
/** Offered too long ago to show a snackbar for now ([AlarmUndo.isStale]). */
/** Offered too long ago to show a chip for now ([AlarmUndo.isStale]). */
fun isUndoStale(deleted: DeletedAlarm): Boolean = undo.isStale(deleted)
/** The snackbar is up; only its answer settles the deletion now. */
/** The chip is up; only its answer settles the deletion now. */
fun onUndoShown(deleted: DeletedAlarm) = undo.markShown(deleted)
/** Dismisses a ring first when switching off (M5 D11), then re-resolves. */
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.clockula.ui.common
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalAccessibilityManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
/**
* Shows [pending]'s "deleted · Undo" as the family's [SnackChip], in a
* FAB-height band along the bottom edge, and reports its one answer: [onUndo]
* for the action, [onExpired] for anything else.
*
* Keyed on the deletion, so a newer one replaces the chip on screen and the
* older delete stands. A chip 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 chip again. A deletion that
* was offered while no screen was there to show it, and is [isStale] by the
* time one is, expires without a chip.
*
* The chip carries no timer of its own, so the window is a long snackbar's,
* stretched the way the accessibility settings ask. [endClearance] is what the
* band keeps free at its end for a FAB sharing it.
*/
@Composable
internal fun <T : Any> UndoChipBand(
pending: T?,
message: String,
onUndo: (T) -> Unit,
onExpired: (T) -> Unit,
isStale: (T) -> Boolean,
onShown: (T) -> Unit,
modifier: Modifier = Modifier,
endClearance: Dp = 0.dp,
) {
val activity = LocalActivity.current
val accessibility = LocalAccessibilityManager.current
val currentMessage by rememberUpdatedState(message)
val currentOnUndo by rememberUpdatedState(onUndo)
val currentOnExpired by rememberUpdatedState(onExpired)
// Kept after the chip hides, so its exit animation still has words in it:
// the message is built from the deletion, which an Undo clears at once.
var shownMessage by remember { mutableStateOf("") }
// Non-null exactly while an Undo is on offer; the action completes it.
var offer by remember { mutableStateOf<CompletableDeferred<Unit>?>(null) }
LaunchedEffect(pending) {
val deleted = pending ?: return@LaunchedEffect
if (isStale(deleted)) {
currentOnExpired(deleted)
return@LaunchedEffect
}
onShown(deleted)
val tapped = CompletableDeferred<Unit>()
shownMessage = currentMessage
offer = tapped
val window = accessibility?.calculateRecommendedTimeoutMillis(
UNDO_WINDOW_MILLIS,
containsIcons = false,
containsText = true,
containsControls = true,
) ?: UNDO_WINDOW_MILLIS
val undone = try {
withTimeoutOrNull(window) { tapped.await() } != null
} catch (cancelled: CancellationException) {
if (activity?.isChangingConfigurations != true) currentOnExpired(deleted)
throw cancelled
} finally {
if (offer === tapped) offer = null
}
if (undone) currentOnUndo(deleted) else currentOnExpired(deleted)
}
BoxWithConstraints(
modifier = modifier
.fillMaxWidth()
.padding(start = SnackChipMargin, end = SnackChipMargin, bottom = SnackChipMargin)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
) {
SnackChip(
visible = offer != null,
message = shownMessage,
modifier = Modifier.fillMaxHeight(),
actionLabel = stringResource(R.string.action_undo),
onAction = { offer?.complete(Unit) },
maxWidth = maxWidth - endClearance,
color = MaterialTheme.colorScheme.secondaryContainer,
)
}
}
/** A long snackbar's window. */
private const val UNDO_WINDOW_MILLIS = 10_000L
@@ -8,11 +8,11 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* The one deletion an Undo snackbar can still bring back: offered by a delete,
* claimed once by the snackbar's answer.
* The one deletion an Undo chip can still bring back: offered by a delete,
* claimed once by the chip's answer.
*
* Deliberately memory only: an Undo that survived process death would be
* offered with no snackbar anyone remembers.
* offered with no chip anyone remembers.
*/
open class UndoSlot<T : Any>(private val clock: ElapsedRealtimeClock) {
@@ -21,14 +21,14 @@ open class UndoSlot<T : Any>(private val clock: ElapsedRealtimeClock) {
@Volatile
private var offeredAt: Duration = Duration.ZERO
/** The offer a snackbar has already been put up for, which can no longer go stale. */
/** The offer a chip has already been put up for, which can no longer go stale. */
@Volatile
private var shown: T? = null
/** Null when there is nothing to undo. */
val pending: StateFlow<T?> = slot.asStateFlow()
/** A newer deletion replaces an older one: only the latest snackbar is on screen. */
/** A newer deletion replaces an older one: only the latest chip is on screen. */
fun offer(deleted: T) {
offeredAt = clock.elapsedRealtime()
shown = null
@@ -38,12 +38,12 @@ open class UndoSlot<T : Any>(private val clock: ElapsedRealtimeClock) {
/**
* Claims [deleted] for its one answer. False when it has already been
* answered or replaced, so a second tap cannot restore it twice and a
* stale snackbar cannot drop a newer deletion.
* stale chip cannot drop a newer deletion.
*/
fun take(deleted: T): Boolean = slot.compareAndSet(deleted, null)
/**
* Whether [deleted] was offered too long ago to put a snackbar up for now.
* Whether [deleted] was offered too long ago to put a chip up for now.
* A delete can land while no list is on screen to show it (the app sent
* to the background the moment the editor's Delete was tapped); offered
* hours later, its Undo would restore a snapshot out of any context. Never
@@ -53,7 +53,7 @@ open class UndoSlot<T : Any>(private val clock: ElapsedRealtimeClock) {
slot.value == deleted && shown != deleted && clock.elapsedRealtime() - offeredAt > SHOW_WITHIN
/**
* A snackbar is up for [deleted]. From now on only its answer settles it, so
* A chip is up for [deleted]. From now on only its answer settles it, so
* a rotation after a long accessibility timeout still shows it again.
*/
fun markShown(deleted: T) {
@@ -1,60 +0,0 @@
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. A deletion
* that was offered while no screen was there to show it, and is [isStale] by
* the time one is, expires without a snackbar.
*/
@Composable
internal fun <T : Any> UndoSnackbarEffect(
pending: T?,
host: SnackbarHostState,
message: String,
onUndo: (T) -> Unit,
onExpired: (T) -> Unit,
isStale: (T) -> Boolean,
onShown: (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
if (isStale(deleted)) {
currentOnExpired(deleted)
return@LaunchedEffect
}
onShown(deleted)
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)
}
}
}
@@ -67,7 +67,7 @@ import kotlin.time.Duration.Companion.minutes
* never from enlarging text", and timer progress is one of the three places §8
* sanctions a showpiece (D29).
*
* The top-right delete button asks nothing first: the list's snackbar offers
* The top-right delete button asks nothing first: the list's chip offers
* an Undo that puts the timer back still counting, which covers the slip a
* dialog would without slowing every delete the user meant.
*/
@@ -1,9 +1,16 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
@@ -12,14 +19,12 @@ 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.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -27,9 +32,11 @@ 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.common.UndoChipBand
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
import de.jeanlucmakiola.floret.identity.animateItemMotion
/**
@@ -55,98 +62,103 @@ fun TimersScreen(
val pillInset = LocalLivePillInset.current
val addTimer = stringResource(R.string.timers_set_title)
val pendingUndo by viewModel.pendingUndo.collectAsStateWithLifecycle()
val snackbar = remember { SnackbarHostState() }
val undoMessage = stringResource(R.string.timer_deleted)
UndoSnackbarEffect(
pending = pendingUndo,
host = snackbar,
message = undoMessage,
onUndo = viewModel::onUndoDelete,
onExpired = viewModel::onUndoExpired,
isStale = viewModel::isUndoStale,
onShown = viewModel::onUndoShown,
)
val loaded = state as? TimersUiState.Loaded
val rows = loaded?.rows
val hasFab = rows != null && rows.isNotEmpty()
// Deleting the last timer leaves the keypad on screen, and the Undo chip
// would sit on its bottom row: the keypad, which shrinks to fit, gives up
// the chip's band while an Undo is on offer.
val undoBand by animateDpAsState(
targetValue = if (!hasFab && pendingUndo != null) SnackChipHeight + SnackChipMargin else 0.dp,
label = "undo-band",
)
CollapsingScaffold(
title = stringResource(R.string.tab_timers),
modifier = modifier,
scrollable = false,
// Above the pill: over a list the FAB (padded by the same inset) lifts
// the snackbar, but an empty tab has no FAB to stand on.
snackbarHost = {
SnackbarHost(
snackbar,
modifier = Modifier.padding(bottom = if (rows.isNullOrEmpty()) pillInset else 0.dp),
)
},
actions = {
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
)
}
},
floatingActionButton = {
// Only over a list: on an empty tab the keypad is already on
// screen, and a FAB that opens what is in front of you is noise.
if (rows != null && rows.isNotEmpty()) {
FloatingActionButton(
onClick = onAddTimer,
// The pill is bottom-centre and the FAB bottom-end, so on a
// narrow phone they would otherwise overlap (M5 D6).
modifier = Modifier.padding(bottom = pillInset),
) {
Icon(Icons.Rounded.Add, contentDescription = addTimer)
}
}
},
) {
if (rows != null && rows.isEmpty()) {
// Outside the list, not an item of it: a lazy item has unbounded
// height, and the keypad needs the real height to fit itself into.
InlineTimerSetup(
onStartNew = viewModel::onStartNew,
modifier = Modifier
.fillMaxSize()
.padding(bottom = pillInset),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
// Disconnected cards, exactly as the Alarms tab's own list is: each
// timer is its own object, not a row of a table.
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(
top = TimersDefaults.ListBottomPadding,
bottom = TimersDefaults.ListBottomPadding + TimersDefaults.FabBand + pillInset,
),
) {
items(rows.orEmpty(), key = { row -> row.id }) { row ->
TimerRow(
row = row,
onOpen = { onEditTimer(row.id) },
onStart = { viewModel.onStart(row.id) },
onPause = { viewModel.onPause(row.id) },
onReset = { viewModel.onReset(row.id) },
onAddMinute = { viewModel.onAddMinute(row.id) },
onSubtractMinute = { viewModel.onSubtractMinute(row.id) },
// No confirmation: the snackbar's Undo puts the timer back,
// still counting (D2's delete still routes through the
// engine, so a ringing timer is stopped cleanly rather than
// deleted into a stuck ring).
onDelete = { viewModel.onDelete(row.id) },
// A timer that finishes stays where it is (D22); the motion
// is for a row arriving or leaving.
modifier = animateItemMotion(),
Box(modifier = modifier.fillMaxSize()) {
CollapsingScaffold(
title = stringResource(R.string.tab_timers),
scrollable = false,
actions = {
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
)
}
},
floatingActionButton = {
// Only over a list: on an empty tab the keypad is already on
// screen, and a FAB that opens what is in front of you is noise.
if (hasFab) {
FloatingActionButton(
onClick = onAddTimer,
// The pill is bottom-centre and the FAB bottom-end, so on a
// narrow phone they would otherwise overlap (M5 D6).
modifier = Modifier.padding(bottom = pillInset),
) {
Icon(Icons.Rounded.Add, contentDescription = addTimer)
}
}
},
) {
if (rows != null && rows.isEmpty()) {
// Outside the list, not an item of it: a lazy item has unbounded
// height, and the keypad needs the real height to fit itself into.
InlineTimerSetup(
onStartNew = viewModel::onStartNew,
modifier = Modifier
.fillMaxSize()
.padding(bottom = pillInset + undoBand),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
// Disconnected cards, exactly as the Alarms tab's own list is: each
// timer is its own object, not a row of a table.
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(
top = TimersDefaults.ListBottomPadding,
bottom = TimersDefaults.ListBottomPadding + TimersDefaults.FabBand + pillInset,
),
) {
items(rows.orEmpty(), key = { row -> row.id }) { row ->
TimerRow(
row = row,
onOpen = { onEditTimer(row.id) },
onStart = { viewModel.onStart(row.id) },
onPause = { viewModel.onPause(row.id) },
onReset = { viewModel.onReset(row.id) },
onAddMinute = { viewModel.onAddMinute(row.id) },
onSubtractMinute = { viewModel.onSubtractMinute(row.id) },
// No confirmation: the chip's Undo puts the timer back,
// still counting (D2's delete still routes through the
// engine, so a ringing timer is stopped cleanly rather than
// deleted into a stuck ring).
onDelete = { viewModel.onDelete(row.id) },
// A timer that finishes stays where it is (D22); the motion
// is for a row arriving or leaving.
modifier = animateItemMotion(),
)
}
}
}
}
// Beside the FAB over a list, measured from the same insets the scaffold
// gives it; above the pill either way.
UndoChipBand(
pending = pendingUndo,
message = undoMessage,
onUndo = viewModel::onUndoDelete,
onExpired = viewModel::onUndoExpired,
isStale = viewModel::isUndoStale,
onShown = viewModel::onUndoShown,
endClearance = if (hasFab) TimersDefaults.FabBand else 0.dp,
modifier = Modifier
.align(Alignment.BottomStart)
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom))
.padding(bottom = pillInset),
)
}
}
@@ -90,15 +90,15 @@ class TimersViewModel @Inject internal constructor(
private val undo = UndoSlot<Timer>(elapsed)
/**
* The deleted timer the snackbar offers to bring back. Null when none. Held
* here, not in the screen, so a rotation re-shows the snackbar rather than
* The deleted timer the chip offers to bring back. Null when none. Held
* here, not in the screen, so a rotation re-shows the chip rather than
* losing the Undo; a newer delete replaces it.
*/
val pendingUndo: StateFlow<Timer?> = undo.pending
/**
* Through the engine, so a *ringing* timer cannot be deleted into a stuck
* ring (D2). Still no confirmation; the snackbar's Undo now covers the slip.
* ring (D2). Still no confirmation; the chip's Undo now covers the slip.
*/
fun onDelete(timerId: Long) {
// On the application scope, as the alarm list's is: a back press while
@@ -107,7 +107,7 @@ class TimersViewModel @Inject internal constructor(
}
/**
* The snackbar's Undo. Claimed first, so a double tap restores once, and
* The chip'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.
*/
@@ -116,15 +116,15 @@ class TimersViewModel @Inject internal constructor(
appScope.launch { engine.restore(timer) }
}
/** The snackbar timed out or was swiped away: the delete stands. */
/** The chip timed out: the delete stands. */
fun onUndoExpired(timer: Timer) {
undo.take(timer)
}
/** Offered too long ago to show a snackbar for now ([UndoSlot.isStale]). */
/** Offered too long ago to show a chip for now ([UndoSlot.isStale]). */
fun isUndoStale(timer: Timer): Boolean = undo.isStale(timer)
/** The snackbar is up; only its answer settles the deletion now. */
/** The chip is up; only its answer settles the deletion now. */
fun onUndoShown(timer: Timer) = undo.markShown(timer)
fun onSavePreset(duration: Duration) {
@@ -407,7 +407,7 @@ class AlarmsViewModelTest {
}
@Test
fun `a timed-out snackbar lets the delete stand`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
fun `a timed-out Undo chip lets the delete stand`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 7)
val viewModel = viewModel(harness)
+9 -9
View File
@@ -1725,10 +1725,10 @@ challenge. The engine re-checks under its lock (`delete(id, unlessRinging = true
so a swipe released just as the alarm fires, before the list has caught up,
deletes nothing and the card slides back. The editor's Delete passes the same
flag: with its confirmation gone, one tap there would otherwise do what the
swipe is kept from doing, so a refused tap opens the ring screen instead, where the alarm is dealt with. Each is followed by a snackbar on the list ("07:00 alarm deleted",
swipe is kept from doing, so a refused tap opens the ring screen instead, where the alarm is dealt with. Each is followed by a chip 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
than the chip does: both only catch the slip noticed in the moment, and a
confirmation the user taps through out of habit catches less. The Undo does it
without taxing every delete the user meant, so the editor's dialog went too
rather than leaving two delete paths with different safety nets.
@@ -1754,13 +1754,13 @@ had not been deleted", and that alarm would have rung.
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 (`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
is cleared before any chip could be answered; the list underneath reads the
deletion from the singleton and shows the chip (`UndoChipBand`). It
is cleared when the chip is answered or times out, when a newer deletion
replaces it, and when the screen is left with the chip 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
was restored meanwhile. A rotation is the one exception and shows the chip
again. A deletion offered while no list was on screen (the app sent home the
instant the editor's Delete was tapped) is dropped unshown if it is more than
30 seconds old by the time the list is back. Both lists share this as
@@ -1808,7 +1808,7 @@ already met once, and the cheapest way not to have it again is to make the wrong
call impossible to write. **Configuration** edits (`rename`, `setDuration`,
`setRingtoneUri`) stay on `TimerRepository` and the editor calls them directly.
A card's corner delete asks nothing first, and the list's snackbar offers an
A card's corner delete asks nothing first, and the list's chip offers an
**Undo**. `TimerEngine.delete` returns the row as `TimerDeletion.snapshot`
decided, and `TimerEngine.restore` puts it back under its own id and place and
resyncs. A running timer comes back with its anchors, so it has kept counting
@@ -1816,7 +1816,7 @@ while it was gone, and one whose end passed in the meantime rings, as it would
have had it never been deleted. A timer that had already finished comes back
reset: deleting it silenced it, which is a dismissal, so it does not ring a
second time (a finished transient timer offers no Undo). The pending row is
`TimersViewModel` state, since both the delete and the snackbar are on the
`TimersViewModel` state, since both the delete and the chip are on the
list; the editor's delete keeps its confirmation.
`TimerRepository` deliberately has **no generic `edit(id, transform)`** the way