From c2a583fda728d16e767a3be9a50e063de0d33364 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 26 Oct 2026 21:51:38 +0100 Subject: [PATCH] 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. --- .../clockula/ui/alarms/AlarmRow.kt | 2 +- .../clockula/ui/alarms/AlarmUndo.kt | 8 +- .../clockula/ui/alarms/AlarmsScreen.kt | 154 ++++++++------- .../clockula/ui/alarms/AlarmsViewModel.kt | 12 +- .../clockula/ui/common/UndoChip.kt | 116 +++++++++++ .../clockula/ui/common/UndoSlot.kt | 16 +- .../clockula/ui/common/UndoSnackbar.kt | 60 ------ .../clockula/ui/timers/TimerRow.kt | 2 +- .../clockula/ui/timers/TimersScreen.kt | 186 ++++++++++-------- .../clockula/ui/timers/TimersViewModel.kt | 14 +- .../clockula/ui/alarms/AlarmsViewModelTest.kt | 2 +- docs/ARCHITECTURE.md | 18 +- 12 files changed, 333 insertions(+), 257 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoChip.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSnackbar.kt 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 4b300d7..f51ce83 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,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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt index ce8b844..51a70f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt @@ -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(clock) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt index fcee832..7643e26 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -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 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 0998fd4..b9eebdd 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 @@ -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 = 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoChip.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoChip.kt new file mode 100644 index 0000000..bbff826 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoChip.kt @@ -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 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?>(null) } + LaunchedEffect(pending) { + val deleted = pending ?: return@LaunchedEffect + if (isStale(deleted)) { + currentOnExpired(deleted) + return@LaunchedEffect + } + onShown(deleted) + val tapped = CompletableDeferred() + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSlot.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSlot.kt index b314020..9e0c183 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSlot.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSlot.kt @@ -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(private val clock: ElapsedRealtimeClock) { @@ -21,14 +21,14 @@ open class UndoSlot(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 = 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(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(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) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSnackbar.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSnackbar.kt deleted file mode 100644 index adf26e2..0000000 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/UndoSnackbar.kt +++ /dev/null @@ -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 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) - } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt index 93d02bf..12a03a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt @@ -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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt index 8a255f5..2351077 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt @@ -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), + ) } } 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 b672fde..8fcd972 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 @@ -90,15 +90,15 @@ class TimersViewModel @Inject internal constructor( private val undo = UndoSlot(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 = 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) { 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 fcb246a..c09c6cb 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 @@ -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) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5f7b50b..476b3a9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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