Carry a month chip back on undo (#68)

Undo moves the event just as the drop did, so it now travels the same
way instead of the chip vanishing off the new day and reappearing on the
old one. The write signals when it starts, the controller puts a copy on
the chip's current seat, and the settle that follows is the drop's own —
glide onto the restored seat, ghost underneath, hand over there.

A week row now publishes what it draws along with where it draws it, so
the controller can find any moved event's seat itself rather than each
row scanning its own lanes.
This commit is contained in:
2026-08-02 19:09:26 +02:00
parent 74780cc212
commit e13b631e10
5 changed files with 156 additions and 46 deletions

View File

@@ -317,6 +317,7 @@ fun CalendarHost(
movableCalendarIds = movableCalendarIds,
move = reschedule::move,
inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted,
edit = onEditEvent,
)
}

View File

@@ -3,8 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -38,6 +42,8 @@ class EventMoveScope(
* and replacing it would recompose all of them twice per drop.
*/
val inFlight: StateFlow<Boolean>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Int>,
/** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit,
) {
@@ -48,6 +54,8 @@ val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
private val NEVER_UNDONE = MutableStateFlow(0)
/**
* Whether a dropped event is still being written — false wherever moving is off.
* The drag overlays hold a landed block on its target for this window.
@@ -58,6 +66,23 @@ fun moveInFlight(): Boolean {
return flow.collectAsStateWithLifecycle().value
}
/**
* Runs [onUndo] when an undo write begins, and never for one that began before
* this composable came on screen — a view switched to *after* an undo has
* nothing left to carry back.
*/
@Composable
fun OnUndoStarted(onUndo: () -> Unit) {
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
val tick by flow.collectAsStateWithLifecycle()
var seen by remember { mutableIntStateOf(tick) }
LaunchedEffect(tick) {
if (tick == seen) return@LaunchedEffect
seen = tick
onUndo()
}
}
/** Opacity the source block keeps while its floating copy travels. */
const val GHOST_ALPHA: Float = 0.3f

View File

@@ -142,6 +142,17 @@ class RescheduleViewModel @Inject constructor(
*/
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
private val _undoStarted = MutableStateFlow(0)
/**
* Ticks the moment an undo write begins — before the provider has anything to
* re-read. An undo moves an event exactly as a drop does, so the view that
* drew the drop takes this as its cue to carry the chip back rather than let
* it reappear on the old day. A counter rather than the undo itself: what a
* view needs is the *timing*, and it already knows what it moved.
*/
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
/**
* Set from the moment a drop is accepted until its write settles. Two drops
* of the same recurring event landing inside that window would each compute
@@ -235,6 +246,7 @@ class RescheduleViewModel @Inject constructor(
fun undo(undo: MoveUndo) {
if (busy) return
busy = true
_undoStarted.value += 1
viewModelScope.launch {
_outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored)

View File

@@ -10,6 +10,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
@@ -20,14 +21,49 @@ class MonthRowGeometry(
val days: List<LocalDate>,
/** The row's day-column box — the space chip offsets are measured in. */
val cell: LayoutCoordinates,
/** The lane band inside that box, where the chips themselves are seated. */
val band: LayoutCoordinates?,
val columnWidthPx: Float,
val laneHeightPx: Float,
val laneCount: Int,
/**
* Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored, but the grid is, so the leftmost column is the *last* day
* in Arabic.
*/
val isRtl: Boolean,
)
/** What this row currently draws in [lane] of column `col`, or null. */
val chipAt: (col: Int, lane: Int) -> EventInstance?,
) {
/** Root top-left of the chip seated in [lane] of column [col]. */
fun seat(col: Int, lane: Int): Offset? {
val band = band?.takeIf { it.isAttached } ?: return null
val origin = band.positionInRoot()
val column = if (isRtl) days.lastIndex - col else col
return Offset(origin.x + column * columnWidthPx, origin.y + lane * laneHeightPx)
}
/**
* Where this row seats [event] on [date], or null when it doesn't hold it —
* the day isn't in this week, or the chip went into the day's "+N" overflow.
*/
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
val lane = (0 until laneCount).firstOrNull { lane ->
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
} ?: return null
return seat(col, lane)
}
}
/**
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither
* id alone will do: the provider hands re-read instances new instance ids, and a
* single-occurrence move writes an exception row with a new event id — the title
* is what survives both.
*/
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
chip.eventId == moved.eventId || chip.title == moved.title
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
data class MonthChipDrag(
@@ -105,6 +141,13 @@ class MonthDragController {
var isDragging: Boolean by mutableStateOf(false)
private set
/**
* The last drop this controller made, kept past [release] for [beginUndo] —
* the confirmation chip carries Undo for four seconds after the copy is long
* gone. Not snapshot state: nothing draws from it.
*/
private var undoable: MonthChipDrag? = null
private var event: EventInstance? = null
private var grabDate: LocalDate? = null
private var grab = Offset.Zero
@@ -164,20 +207,46 @@ class MonthDragController {
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): 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
return isSameEvent(event, landed.event)
}
/**
* 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.
* Ask the row keyed [token] whether it now seats the settled chip, and take
* its answer. The continuous style can show the same week twice, once in each
* adjoining month, so both copies of the landing row will answer: the seat
* nearest where the chip was let go is the one the finger was over.
*/
fun noteSettled(topLeftInRoot: Offset) {
fun noteSettled(token: Any) {
val landed = settling ?: return
val target = landed.targetDate ?: return
val at = rows[token]?.seatOf(landed.event, target) ?: return
val current = settledInRoot
val closer = current == null ||
abs(topLeftInRoot.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
if (closer) settledInRoot = topLeftInRoot
abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
if (closer) settledInRoot = at
}
/**
* Send a copy back for an undo, which moves the event exactly as the drop did
* and so should read the same way rather than teleporting the chip. Nothing
* here writes anything: it puts a chip on the journey the inverse write is
* about to make, and the settle that follows is the drop's own.
*
* False when there is nothing to carry — no drop of this controller's to undo
* (it happened in another view, or has already been undone), or the grid does
* not seat the moved event where it would have to start from. The chip then
* simply reappears on the day it came from, as it always did.
*/
fun beginUndo(): Boolean {
val last = undoable ?: return false
undoable = null
val from = last.targetDate ?: return false
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
liftedInstanceId = last.event.instanceId
settledGhost = true
settledInRoot = null
return true
}
/**
@@ -195,6 +264,7 @@ class MonthDragController {
)
liftedInstanceId = landed.event.instanceId
settledGhost = true
undoable = settling
return MonthChipDrop(landed.event, landed.grabDate, target)
}

View File

@@ -61,6 +61,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -92,6 +93,7 @@ import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
@@ -416,6 +418,10 @@ fun MonthScreen(
// overlay so it is free of the week row's clip and, in the scrolling
// styles, of the list viewport's.
val chipDrag = rememberMonthDragController()
// Undo moves the event back, so the chip travels back too rather than
// reappearing on its old day. The signal comes from the write, not
// from the chip that offers it: that lives above every view.
OnUndoStarted { chipDrag.beginUndo() }
Box(
modifier = Modifier
.padding(innerPadding)
@@ -1860,6 +1866,7 @@ private fun MonthWeekRow(
val dragController = LocalMonthDrag.current
val rowToken = remember { Any() }
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val cellCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val density = LocalDensity.current
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@@ -1867,36 +1874,35 @@ 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
// Republished on layout *and* on every recomposition: the coordinates only
// change on the former, but what the row draws — which the controller reads
// to find a moved chip's seat — changes on the latter, and a re-read that
// moves an event doesn't move the row it lands in.
val publish = {
val cell = cellCoordinates[0]?.takeIf { it.isAttached }
if (dragController != null && cell != null) {
dragController.putRow(
rowToken,
MonthRowGeometry(
days = week.days,
cell = cell,
band = bandCoordinates[0],
columnWidthPx = cell.size.width / 7f,
laneHeightPx = rowHeightPx,
laneCount = MAX_EVENT_ROWS,
isRtl = isRtl,
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
),
)
}
}
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,
),
)
SideEffect { publish() }
// Once the grid holds the settled chip, tell the controller where this row
// has seated it, so the copy in flight can glide onto it. Keyed on the week
// because that is what changes when the re-read lands: until then the row
// still draws the event on the day it came from and answers nothing.
LaunchedEffect(week, dragController?.settling) {
dragController?.noteSettled(rowToken)
}
Row(modifier) {
@@ -1916,15 +1922,8 @@ private fun MonthWeekRow(
.weight(1f)
.fillMaxHeight()
.onGloballyPositioned { coords ->
dragController?.putRow(
rowToken,
MonthRowGeometry(
days = week.days,
cell = coords,
columnWidthPx = coords.size.width / 7f,
isRtl = isRtl,
),
)
cellCoordinates[0] = coords
publish()
}
.then(
monthChipDragModifier(
@@ -2000,7 +1999,10 @@ private fun MonthWeekRow(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.onGloballyPositioned { bandCoordinates[0] = it }
.onGloballyPositioned {
bandCoordinates[0] = it
publish()
}
// A dragged chip travels to another row, so the clip has
// to yield for it exactly as it does for a morph.
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),