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.
This commit is contained in:
2026-08-02 18:12:48 +02:00
parent 5eb7d76f8a
commit 7a86014461
8 changed files with 163 additions and 141 deletions

View File

@@ -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<Dp> = 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)
}

View File

@@ -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).

View File

@@ -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

View File

@@ -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
}

View File

@@ -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<Long, Int>()
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;

View File

@@ -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 {

View File

@@ -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<Long, Int>()
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;