Revert the chip's FAB colours, quicken it, and end the settle flicker (#68)

The chip goes back to the neutral fill and its motion is cut to a receipt's
length — the default visibility spring overshot and read as sluggish.

The landed copy is now handed back when the grid actually draws the drop,
not on a timer: releasing early put the source ghost back at full opacity in
its old slot for the rest of the re-read, which is the flicker.
This commit is contained in:
2026-08-02 18:19:43 +02:00
parent 7a86014461
commit b7e624877d
6 changed files with 59 additions and 18 deletions

View File

@@ -6,8 +6,6 @@ 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.material3.FloatingActionButtonDefaults
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -108,10 +106,6 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
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),
)
}
}

View File

@@ -144,6 +144,15 @@ class TimelineDragController {
var isDragging: Boolean by mutableStateOf(false)
private set
/**
* Whether the grid itself now draws the settled drop at its landing slot.
* The copy may only be handed back once this is true: releasing on a timer
* puts the source ghost back at full opacity in its *old* slot for whatever
* is left of the re-read — a flicker of the event where it no longer is.
*/
var settledOnGrid: Boolean by mutableStateOf(false)
private set
private var source: TimedBlock? = null
private var grab = Offset.Zero
private var pointer = Offset.Zero
@@ -180,6 +189,25 @@ class TimelineDragController {
originSlot = null
drag = null
settling = null
settledOnGrid = false
}
/**
* What a day column now holds, so a settled drop can tell when the grid has
* caught up with it. Matched on the landing slot plus either the event row
* or its title: a single-occurrence move writes an exception row with a new
* `eventId`, which nothing else about the drop can predict.
*/
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
val landed = settling ?: return
if (settledOnGrid || landed.date != date) return
settledOnGrid = blocks.any { block ->
block.startMin == landed.startMin &&
(
block.event.eventId == landed.event.eventId ||
block.event.title == landed.event.title
)
}
}
/**
@@ -201,6 +229,7 @@ class TimelineDragController {
fun release() {
settling = null
liftedInstanceId = null
settledOnGrid = false
}
/**
@@ -301,11 +330,17 @@ 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, and
* releasing on the write alone would still flash the old slot.
* A beat after the grid draws the drop, so its own block is under way before the
* copy starts dissolving.
*/
const val SETTLE_GRACE_MILLIS: Long = 200L
const val SETTLE_GRACE_MILLIS: Long = 60L
/**
* How long to wait for a grid that never confirms the drop — the event landed on
* a day this timeline doesn't show, or the write failed and nothing changed. The
* copy has to go either way.
*/
private const val SETTLE_TIMEOUT_MILLIS = 900L
/**
* The hand-over: the copy dissolves over this while the grid's own block slides
@@ -337,12 +372,12 @@ 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, then
// fade it into the grid's own block, which has slid in underneath by now.
// recurring drop's scope dialog is up — and then until the grid draws the
// drop itself, so the copy dissolves onto a block that is already there.
var handingOver by remember(controller.settling) { mutableStateOf(false) }
LaunchedEffect(controller.settling, moveInFlight) {
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect
delay(SETTLE_GRACE_MILLIS)
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
handingOver = true
delay(SETTLE_FADE_MILLIS.toLong())
controller.release()

View File

@@ -615,6 +615,10 @@ private fun DayColumnCard(
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
}
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.

View File

@@ -92,7 +92,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
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_GRACE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import androidx.compose.ui.unit.IntSize
@@ -466,6 +465,9 @@ 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
/** The chip in flight, drawn over the grid and following the finger. */
@Composable
private fun MonthDragOverlay(controller: MonthDragController) {
@@ -478,10 +480,12 @@ 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.
// 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.
LaunchedEffect(controller.settling, moveInFlight) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect
delay(SETTLE_GRACE_MILLIS)
delay(MONTH_SETTLE_HOLD_MILLIS)
controller.release()
}
Box(

View File

@@ -764,6 +764,10 @@ private fun DayColumnCard(
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
}
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.