Spring the confirmation chip, and build it a frame earlier (#68)

The chip springs open from its corner on the expressive fast specs instead
of sliding in clipped, and its text is resolved in composition rather than
from an effect — a frame late meant it opened carrying the previous message
and grew into the new one mid-animation.

Undo now leaves the outcome standing until its write reports back, so the
one chip changes what it says instead of closing and reopening.
This commit is contained in:
2026-08-02 18:27:44 +02:00
parent b7e624877d
commit 1c9a4f7f50
3 changed files with 22 additions and 12 deletions

View File

@@ -32,6 +32,9 @@ import java.util.Locale
/** How long the confirmation chip stays up, matching a short snackbar. */ /** How long the confirmation chip stays up, matching a short snackbar. */
private const val CHIP_MILLIS = 4_000L private const val CHIP_MILLIS = 4_000L
/** What the chip currently reads, kept past the outcome it was built from. */
private data class ChipContent(val message: String, val undo: MoveUndo?)
/** The FAB's own band at the bottom end, which the chip must not run into. */ /** The FAB's own band at the bottom end, which the chip must not run into. */
private val FAB_BAND = 88.dp private val FAB_BAND = 88.dp
@@ -75,13 +78,16 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
} }
// Held past the outcome being consumed so the chip has something to draw // Held past the outcome being consumed so the chip has something to draw
// while it slides back out. // while it springs back out. Updated *in composition* rather than from an
var shownMessage by remember { mutableStateOf("") } // effect: an effect lands a frame late, so the chip would open carrying the
var shownUndo by remember { mutableStateOf<MoveUndo?>(null) } // previous message and grow into this one while it was still animating in.
val shown = remember { mutableStateOf(ChipContent("", null)) }
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
shown.value = ChipContent(message, moved?.undo)
}
val content = shown.value
LaunchedEffect(outcome) { LaunchedEffect(outcome) {
val text = message ?: return@LaunchedEffect if (outcome == null) return@LaunchedEffect
shownMessage = text
shownUndo = moved?.undo
delay(CHIP_MILLIS) delay(CHIP_MILLIS)
viewModel.consumeOutcome() viewModel.consumeOutcome()
} }
@@ -101,11 +107,11 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
) { ) {
SnackChip( SnackChip(
visible = outcome != null, visible = outcome != null,
message = shownMessage, message = content.message,
maxWidth = chipMaxWidth, maxWidth = chipMaxWidth,
actionLabel = stringResource(R.string.event_move_undo) actionLabel = stringResource(R.string.event_move_undo)
.takeIf { shownUndo != null }, .takeIf { content.undo != null },
onAction = shownUndo?.let { undo -> { viewModel.undo(undo) } }, onAction = content.undo?.let { undo -> { viewModel.undo(undo) } },
) )
} }
} }

View File

@@ -226,11 +226,15 @@ class RescheduleViewModel @Inject constructor(
_scopePrompt.value = null _scopePrompt.value = null
} }
/** Put a completed move back where it came from. */ /**
* Put a completed move back where it came from. The outcome deliberately
* stands until the inverse write reports back: clearing it first would drop
* the confirmation chip and open a second one a moment later, rather than
* letting the one chip change what it says.
*/
fun undo(undo: MoveUndo) { fun undo(undo: MoveUndo) {
if (busy) return if (busy) return
busy = true busy = true
_outcome.value = null
viewModelScope.launch { viewModelScope.launch {
_outcome.value = try { _outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored) repository.updateEvent(undo.eventId, undo.moved, undo.restored)