From 7a86014461ca932f00f5e66ecb047e3eca25507d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 18:12:48 +0200 Subject: [PATCH] Move the chip into floret-kit, and tween the blocks (#68) The confirmation pill is now floret-kit's SnackChip, shared with Agendula's swipe-delete undo, wearing the FAB's colours and crossfading when its message is answered. Timed blocks are keyed by event and their bounds tweened, so a landing drop, the neighbour that has to make room for it, and an undo all slide instead of popping. The source ghost travels with them rather than fading out, and the floating copy dissolves into the block once the grid has caught up. --- .../calendula/ui/common/BlockPlacement.kt | 41 ++++++++ .../calendula/ui/common/EventMoveHost.kt | 98 +++---------------- .../calendula/ui/common/EventMoveScope.kt | 16 ++- .../calendula/ui/common/TimelineDrag.kt | 29 +++++- .../calendula/ui/day/DayScreen.kt | 56 +++++++---- .../calendula/ui/month/MonthScreen.kt | 4 +- .../calendula/ui/week/WeekScreen.kt | 58 +++++++---- floret-kit | 2 +- 8 files changed, 163 insertions(+), 141 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt new file mode 100644 index 0000000..877b3b6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -0,0 +1,41 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.snap +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.ui.unit.Dp +import de.jeanlucmakiola.floret.identity.rememberReduceMotion + +/** Where a timed block sits in its column, after tweening. */ +@Immutable +data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp) + +/** + * A timed block's placement, tweened rather than jumped. Every bound a block + * has changes for a reason the user just caused — a drop landing at a new time, + * an undo putting it back, a neighbour arriving and halving both lanes — and all + * of them read better as motion than as a new layout appearing. + * + * Continuity comes from the caller keying each block by identity; a block + * composed for the first time starts *at* its target, so nothing flies in from + * the corner on the first frame. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement { + val spec: FiniteAnimationSpec = if (rememberReduceMotion()) { + snap() + } else { + MaterialTheme.motionScheme.fastSpatialSpec() + } + val animatedX by animateDpAsState(x, spec, label = "block-x") + val animatedY by animateDpAsState(y, spec, label = "block-y") + val animatedWidth by animateDpAsState(width, spec, label = "block-width") + val animatedHeight by animateDpAsState(height, spec, label = "block-height") + return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt index 3b67f95..4b3b508 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -1,25 +1,13 @@ package de.jeanlucmakiola.calendula.ui.common -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -29,14 +17,12 @@ 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.semantics.LiveRegionMode -import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.floret.components.SnackChip +import de.jeanlucmakiola.floret.components.SnackChipHeight +import de.jeanlucmakiola.floret.components.SnackChipMargin import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.localizedDateFormatter import kotlinx.coroutines.delay @@ -51,8 +37,6 @@ private const val CHIP_MILLIS = 4_000L /** The FAB's own band at the bottom end, which the chip must not run into. */ private val FAB_BAND = 88.dp -private val CHIP_HEIGHT = 56.dp - /** * The two surfaces a drag-and-drop reschedule needs on top of the calendar: the * recurring-scope prompt, and the confirmation chip carrying Undo. @@ -113,78 +97,26 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) modifier = Modifier .align(Alignment.BottomStart) .navigationBarsPadding() - .padding(start = 16.dp, bottom = 16.dp) - .height(CHIP_HEIGHT), + .padding(start = SnackChipMargin, bottom = SnackChipMargin) + .height(SnackChipHeight), contentAlignment = Alignment.CenterStart, ) { - MoveChip( + SnackChip( visible = outcome != null, message = shownMessage, maxWidth = chipMaxWidth, - onUndo = shownUndo?.let { undo -> { viewModel.undo(undo) } }, + actionLabel = stringResource(R.string.event_move_undo) + .takeIf { shownUndo != null }, + onAction = shownUndo?.let { undo -> { viewModel.undo(undo) } }, + // The FAB's own colours: the chip shares its band, so sharing its + // fill reads as one bottom layer rather than two competing ones. + color = FloatingActionButtonDefaults.containerColor, + contentColor = contentColorFor(FloatingActionButtonDefaults.containerColor), ) } } } -/** - * The confirmation pill: sized to its content, sliding up from the bottom, with - * Undo where the write has a clean inverse. - */ -@Composable -private fun MoveChip( - visible: Boolean, - message: String, - maxWidth: Dp, - onUndo: (() -> Unit)?, -) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically { it } + fadeIn(), - exit = slideOutVertically { it } + fadeOut(), - ) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(50), - shadowElevation = 6.dp, - modifier = Modifier.widthIn(max = maxWidth), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding( - start = 20.dp, - end = if (onUndo != null) 8.dp else 20.dp, - top = 6.dp, - bottom = 6.dp, - ), - ) { - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f, fill = false) - .semantics { liveRegion = LiveRegionMode.Polite }, - ) - if (onUndo != null) { - TextButton( - onClick = onUndo, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), - ) { - Text( - text = stringResource(R.string.event_move_undo), - style = MaterialTheme.typography.labelLarge, - ) - } - } - } - } - } -} - /** * "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events * are read back on the UTC calendar day they are anchored to (#65, #82). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt index 554c4a2..cfae0b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt @@ -62,17 +62,15 @@ fun moveInFlight(): Boolean { const val GHOST_ALPHA: Float = 0.3f /** - * Opacity for a block whose copy is in flight: ghosted while the finger holds - * it, then faded out once the copy has landed, so the slot it came from is - * already empty by the time the grid re-reads the move. + * Opacity for a block whose copy is in flight: ghosted from the lift until the + * copy is handed back, then animated up rather than switched. The ghost keeps + * its place through the write and *travels* to the new slot when the grid + * re-reads it, arriving under the copy as that fades — so what the eye follows + * is one block moving, not one vanishing and another appearing. */ @Composable -fun ghostAlpha(lifted: Boolean, landed: Boolean): Float = animateFloatAsState( - targetValue = when { - !lifted -> 1f - landed -> 0f - else -> GHOST_ALPHA - }, +fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState( + targetValue = if (lifted) GHOST_ALPHA else 1f, label = "ghost-alpha", ).value diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index bac2b14..c6818f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ScrollState import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.background @@ -300,11 +301,19 @@ fun TimelineDrop.startInstant(zone: TimeZone): Instant = LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone) /** - * How long a landed block keeps its position after the write reports back. The - * provider's change notification and the re-query land a beat later; releasing - * on the write alone would still flash the old slot. + * How long a landed block keeps its position after the write reports back — the + * provider's change notification and the re-query land a beat later, and + * releasing on the write alone would still flash the old slot. */ -const val SETTLE_GRACE_MILLIS: Long = 450L +const val SETTLE_GRACE_MILLIS: Long = 200L + +/** + * The hand-over: the copy dissolves over this while the grid's own block slides + * in under it, so the two overlap rather than one replacing the other. Kept + * short, because a full-width copy sits over any neighbour it now shares a lane + * with until it is gone. + */ +const val SETTLE_FADE_MILLIS: Int = 250 /** * The floating block, drawn over the whole calendar so it is free of the day @@ -328,10 +337,14 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = if (controller.isDragging) controller.autoScroll() } // Hold the landed copy until the write is done — including the whole time a - // recurring drop's scope dialog is up — then a grace for the re-read. + // recurring drop's scope dialog is up — then a grace for the re-read, then + // fade it into the grid's own block, which has slid in underneath by now. + var handingOver by remember(controller.settling) { mutableStateOf(false) } LaunchedEffect(controller.settling, moveInFlight) { if (controller.settling == null || moveInFlight) return@LaunchedEffect delay(SETTLE_GRACE_MILLIS) + handingOver = true + delay(SETTLE_FADE_MILLIS.toLong()) controller.release() } @@ -351,6 +364,11 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = targetValue = if (landed || reduceMotion) 0f else 1f, label = "drag-lift", ) + val copyAlpha by animateFloatAsState( + targetValue = if (handingOver) 0f else 1f, + animationSpec = tween(SETTLE_FADE_MILLIS), + label = "drag-handover", + ) val fill = eventFill(drag.event.color, dark, soften) val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" + @@ -374,6 +392,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = scaleX = 1f + 0.02f * lift scaleY = 1f + 0.02f * lift shadowElevation = 8.dp.toPx() * lift + alpha = copyAlpha shape = RoundedCornerShape(4.dp) clip = false } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 0d64220..3fdb258 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -79,6 +80,7 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.MoveRequest @@ -640,25 +642,39 @@ private fun DayColumnCard( ) { val colWidth = maxWidth val minEventHeight = hourHeight * MIN_EVENT_FRACTION + // Keyed by event, so a block that changes time or lane is the *same* + // composable afterwards and tweens there. The ordinal disambiguates + // the rare column holding two occurrences of one series, which would + // otherwise be two blocks under one key. + val ordinals = mutableMapOf() blocks.forEach { block -> - val laneWidth = colWidth / block.laneCount - val top = hourHeight * (block.startMin / 60f) - val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight - EventBlock( - block = block, - dark = dark, - height = height, - date = date, - dragController = dragController, - onClick = { onEventClick(block.event) }, - onDrop = onDrop, - modifier = Modifier - .offset(x = laneWidth * block.lane, y = top) - .width(laneWidth) - .height(height) - .padding(horizontal = 1.dp), - ) + val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 + key(block.event.eventId, ordinal) { + val laneWidth = colWidth / block.laneCount + val top = hourHeight * (block.startMin / 60f) + val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) + val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight + val place = animatedBlockPlacement( + x = laneWidth * block.lane, + y = top, + width = laneWidth, + height = height, + ) + EventBlock( + block = block, + dark = dark, + height = place.height, + date = date, + dragController = dragController, + onClick = { onEventClick(block.event) }, + onDrop = onDrop, + modifier = Modifier + .offset(x = place.x, y = place.y) + .width(place.width) + .height(place.height) + .padding(horizontal = 1.dp), + ) + } } // Current-time line, on top of the events, only on today's column. if (date == today) { @@ -714,12 +730,12 @@ private fun EventBlock( onCancel = dragController::cancel, ) val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId - val ghost = ghostAlpha(lifted, landed = dragController.settling != null) + val ghost = ghostAlpha(lifted) Box( modifier = modifier // The source stays put as a ghost while its floating copy travels, // then fades out as the copy settles on its new slot. - .then(if (lifted) Modifier.alpha(ghost) else Modifier) + .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .background(fill, RoundedCornerShape(4.dp)) .clickable(onClick = onClick) // After clickable, so it is the inner node and wins the main pass; diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 364aaf4..f782d94 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -2266,7 +2266,7 @@ private fun MonthBar( // fades out as the copy settles on its new day. val monthDrag = LocalMonthDrag.current val lifted = monthDrag?.liftedInstanceId == event.instanceId - val ghost = ghostAlpha(lifted, landed = monthDrag?.settling != null) + val ghost = ghostAlpha(lifted) val shape = RoundedCornerShape( topStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp, @@ -2275,7 +2275,7 @@ private fun MonthBar( ) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) - .then(if (lifted) Modifier.alpha(ghost) else Modifier) + .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .background(fill, shape) .padding(horizontal = 4.dp) .semantics { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index c189e3c..e8f0759 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.key import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -88,6 +89,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.MoveRequest @@ -788,26 +790,40 @@ private fun DayColumnCard( ) { val colWidth = maxWidth val minEventHeight = hourHeight * MIN_EVENT_FRACTION + // Keyed by event, so a block that changes time or lane is the *same* + // composable afterwards and tweens there. The ordinal disambiguates + // the rare column holding two occurrences of one series, which would + // otherwise be two blocks under one key. + val ordinals = mutableMapOf() blocks.forEach { block -> - val laneWidth = colWidth / block.laneCount - val top = hourHeight * (block.startMin / 60f) - val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight - EventBlock( - block = block, - dark = dark, - height = height, - width = laneWidth, - date = date, - dragController = dragController, - onClick = { onEventClick(block.event) }, - onDrop = onDrop, - modifier = Modifier - .offset(x = laneWidth * block.lane, y = top) - .width(laneWidth) - .height(height) - .padding(horizontal = 1.dp), - ) + val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 + key(block.event.eventId, ordinal) { + val laneWidth = colWidth / block.laneCount + val top = hourHeight * (block.startMin / 60f) + val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) + val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight + val place = animatedBlockPlacement( + x = laneWidth * block.lane, + y = top, + width = laneWidth, + height = height, + ) + EventBlock( + block = block, + dark = dark, + height = place.height, + width = place.width, + date = date, + dragController = dragController, + onClick = { onEventClick(block.event) }, + onDrop = onDrop, + modifier = Modifier + .offset(x = place.x, y = place.y) + .width(place.width) + .height(place.height) + .padding(horizontal = 1.dp), + ) + } } // Current-time line, on top of the events, only on today's column. if (date == today) { @@ -883,12 +899,12 @@ private fun EventBlock( onCancel = dragController::cancel, ) val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId - val ghost = ghostAlpha(lifted, landed = dragController.settling != null) + val ghost = ghostAlpha(lifted) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) // The source stays put as a ghost while its floating copy travels, // then fades out as the copy settles on its new slot. - .then(if (lifted) Modifier.alpha(ghost) else Modifier) + .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .background(fill, RoundedCornerShape(4.dp)) .clickable(onClick = onClick) // After clickable, so it is the inner node and wins the main pass; diff --git a/floret-kit b/floret-kit index ed1d3ca..0ce855c 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit ed1d3ca5e8ff0d63e2ed9d7ece25bcfbcd3be02d +Subproject commit 0ce855c925c9e7af64f10b2595bf151f59f37bd5