From 74780cc212f32ff73a0dfab64e9baf2507426a8b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 18:56:23 +0200 Subject: [PATCH] Land a month chip on its seat instead of waiting it out (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The month grid seats a dropped chip in a lane, but the copy was left at whatever height the finger was at for a flat 450ms hold and then cross-faded across that gap — long enough that the event looked like it took its time arriving. The week row now reports where the grid actually seats the drop once the re-read lands, and the copy glides onto it and hands over there. The flat wait is only the fallback for a drop with no seat to find (a day's "+N" overflow, or a failed write). --- .../calendula/ui/month/MonthDrag.kt | 53 ++++++++++++ .../calendula/ui/month/MonthScreen.kt | 85 ++++++++++++++++--- 2 files changed, 127 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt index 43698fe..8716d47 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.unit.IntSize import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.datetime.LocalDate +import kotlin.math.abs /** One week row's live geometry, republished on every layout while it is on screen. */ class MonthRowGeometry( @@ -80,6 +81,26 @@ class MonthDragController { var liftedInstanceId: Long? by mutableStateOf(null) private set + /** + * Where the grid itself now draws the drop, in root coordinates — published + * by whichever week row ends up holding it. The copy is dropped wherever the + * finger was, but the grid seats it in a lane; without this the copy waits + * out a flat hold beside the chip the grid has already drawn and then + * cross-fades across the gap, which reads as the event taking its time to + * arrive. With it the copy glides onto its seat and hands over there. + */ + var settledInRoot: Offset? by mutableStateOf(null) + private set + + /** + * Whether the drop's own chip is still standing in for the copy. Cleared at + * [handOver] rather than at [release], for the same reason [liftedInstanceId] + * is: held to the end it would keep the chip dim under a copy that has + * already faded, and brighten it afterwards — a dip the eye reads as the + * event flickering. + */ + private var settledGhost: Boolean by mutableStateOf(false) + /** Whether a finger is on a chip right now — [settling] is not dragging. */ var isDragging: Boolean by mutableStateOf(false) private set @@ -128,6 +149,35 @@ class MonthDragController { liftedInstanceId = null drag = null settling = null + settledInRoot = null + settledGhost = false + } + + /** + * Whether [days] holds the chip the grid has drawn for the settled drop — + * the seat the copy is on its way to, which must ghost until it gets there. + * Matched on the landing day plus either the event row or its title: a + * single-occurrence move writes an exception row with a new `eventId`, and + * the provider hands re-read instances new ids, so neither alone identifies + * the chip the drop became. + */ + fun isSettledChip(event: EventInstance, days: List?): Boolean { + val landed = settling?.takeIf { settledGhost } ?: return false + if (days == null || landed.targetDate !in days) return false + return event.eventId == landed.event.eventId || event.title == landed.event.title + } + + /** + * Publish a seat. The continuous style can show the same week twice, once in + * each adjoining month, so both copies of the landing row offer one: the one + * nearest where the chip was dropped is the one the finger was over. + */ + fun noteSettled(topLeftInRoot: Offset) { + val landed = settling ?: return + val current = settledInRoot + val closer = current == null || + abs(topLeftInRoot.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y) + if (closer) settledInRoot = topLeftInRoot } /** @@ -144,6 +194,7 @@ class MonthDragController { topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y), ) liftedInstanceId = landed.event.instanceId + settledGhost = true return MonthChipDrop(landed.event, landed.grabDate, target) } @@ -154,11 +205,13 @@ class MonthDragController { */ fun handOver() { liftedInstanceId = null + settledGhost = false } /** Drop the copy, once it has faded into the grid's own chip. */ fun release() { settling = null + settledInRoot = null handOver() } 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 b812f29..37070c5 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 @@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.animateFloat @@ -93,6 +95,7 @@ import de.jeanlucmakiola.calendula.ui.common.moveInFlight import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS +import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.domain.spanFirstDay import androidx.compose.ui.unit.IntSize @@ -466,8 +469,12 @@ fun MonthScreen( } } -/** How long a landed chip holds its day while the write and re-read land. */ -private const val MONTH_SETTLE_HOLD_MILLIS = 450L +/** + * How long to wait for a grid that never seats the drop — it landed in a day's + * "+N" overflow, or on a day this month doesn't show, or the write failed and + * nothing changed. The copy has to go either way. + */ +private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L /** The chip in flight, drawn over the grid and following the finger. */ @Composable @@ -481,13 +488,26 @@ private fun MonthDragOverlay(controller: MonthDragController) { // every frame, and this composable is the one that is meant to. DragSnapHaptics(controller.drag?.targetDate) // Hold the landed chip until the write is done — including the whole time a - // recurring drop's scope dialog is up — then a grace for the re-read. A flat - // wait, unlike the timeline's: the grid re-lays a whole month of rows, so - // there is no single column to watch for the chip arriving. + // recurring drop's scope dialog is up — and then, unlike the timeline, carry + // it to its seat: a chip is dropped at whatever height the finger was at, + // while the grid seats it in a lane, so the copy has a gap to close before + // the two are the same chip and the hand-over can be invisible. var handingOver by remember(controller.settling) { mutableStateOf(false) } - LaunchedEffect(controller.settling, moveInFlight) { - if (controller.settling == null || moveInFlight) return@LaunchedEffect - delay(MONTH_SETTLE_HOLD_MILLIS) + var gliding by remember(controller.settling) { mutableStateOf(false) } + val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) } + val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec() + val seat = controller.settledInRoot + LaunchedEffect(controller.settling, moveInFlight, seat) { + val landed = controller.settling + if (landed == null || moveInFlight) return@LaunchedEffect + if (seat == null) { + delay(MONTH_SETTLE_TIMEOUT_MILLIS) + } else { + if (!gliding) glide.snapTo(landed.topLeftInRoot) + gliding = true + if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec) + delay(SETTLE_GRACE_MILLIS) + } handingOver = true controller.handOver() delay(SETTLE_FADE_MILLIS.toLong()) @@ -522,9 +542,10 @@ private fun MonthDragOverlay(controller: MonthDragController) { // Absolute: these are root coordinates, and the direction-aware // offset would mirror them across the screen in an RTL layout. .absoluteOffset { + val at = if (gliding) glide.value else drag.topLeftInRoot IntOffset( - (drag.topLeftInRoot.x - origin.x).roundToInt(), - (drag.topLeftInRoot.y - origin.y).roundToInt(), + (at.x - origin.x).roundToInt(), + (at.y - origin.y).roundToInt(), ) } .width(with(density) { drag.sizePx.width.toDp() }) @@ -1846,6 +1867,37 @@ private fun MonthWeekRow( DisposableEffect(rowToken, dragController) { onDispose { dragController?.removeRow(rowToken) } } + // Where this row seats a drop that landed on one of its days, once the + // re-read arrives — the copy in flight glides onto it. Null until the grid + // actually holds the moved event: before the re-read the week still shows it + // on the day it came from, and a chip that lands in the "+N" overflow gets no + // seat at all. + val landed = dragController?.settling + val seat = if (landed == null) { + null + } else { + remember(week, landed) { + val target = landed.targetDate ?: return@remember null + val col = week.days.indexOf(target).takeIf { it >= 0 } ?: return@remember null + val lane = (0 until MAX_EVENT_ROWS).firstOrNull { lane -> + val chip = week.chipAt(col, lane, MAX_EVENT_ROWS) + chip != null && dragController.isSettledChip(chip, listOf(target)) + } ?: return@remember null + col to lane + } + } + LaunchedEffect(seat, dragController) { + val (col, lane) = seat ?: return@LaunchedEffect + val band = bandCoordinates[0]?.takeIf { it.isAttached } ?: return@LaunchedEffect + val origin = band.positionInRoot() + val column = if (isRtl) week.days.lastIndex - col else col + dragController?.noteSettled( + Offset( + x = origin.x + column * (band.size.width / 7f), + y = origin.y + lane * rowHeightPx, + ), + ) + } Row(modifier) { // Optional calendar-week gutter, sized so the seven day columns below @@ -1961,6 +2013,7 @@ private fun MonthWeekRow( dark = dark, continuesLeft = span.continuesLeft, continuesRight = span.continuesRight, + days = week.days.subList(span.startCol, span.endCol + 1), modifier = Modifier .offset( x = colW * span.startCol, @@ -2030,6 +2083,7 @@ private fun MonthWeekRow( dark = dark, continuesLeft = false, continuesRight = false, + days = listOf(d), modifier = Modifier .offset( x = colW * col, @@ -2270,6 +2324,14 @@ private fun MonthBar( continuesLeft: Boolean, continuesRight: Boolean, modifier: Modifier = Modifier, + /** + * The days this chip covers in its row, for the drag (#68). A drop's own chip + * has to ghost like the source it came from until the copy has landed on it, + * and the instance id can't find it: the provider hands the re-read instance a + * new one, so the chip the drop became would otherwise sit at full opacity + * under the copy still travelling towards it — the event drawn twice. + */ + days: List? = null, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val dimCutoff = LocalDimCutoff.current @@ -2280,7 +2342,8 @@ private fun MonthBar( // The source stays put as a ghost while its floating copy travels, then // fades out as the copy settles on its new day. val monthDrag = LocalMonthDrag.current - val lifted = monthDrag?.liftedInstanceId == event.instanceId + val lifted = monthDrag?.liftedInstanceId == event.instanceId || + monthDrag?.isSettledChip(event, days) == true val ghost = ghostAlpha(lifted) val shape = RoundedCornerShape( topStart = if (continuesLeft) 0.dp else 4.dp,