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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user