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, movableCalendarIds = movableCalendarIds,
move = reschedule::move, move = reschedule::move,
inFlight = reschedule.inFlight, inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted,
edit = onEditEvent, edit = onEditEvent,
) )
} }

View File

@@ -3,8 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -38,6 +42,8 @@ class EventMoveScope(
* and replacing it would recompose all of them twice per drop. * and replacing it would recompose all of them twice per drop.
*/ */
val inFlight: StateFlow<Boolean>, 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. */ /** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit, val edit: (EventInstance) -> Unit,
) { ) {
@@ -48,6 +54,8 @@ val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
private val NEVER_IN_FLIGHT = MutableStateFlow(false) 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. * 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. * The drag overlays hold a landed block on its target for this window.
@@ -58,6 +66,23 @@ fun moveInFlight(): Boolean {
return flow.collectAsStateWithLifecycle().value 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. */ /** Opacity the source block keeps while its floating copy travels. */
const val GHOST_ALPHA: Float = 0.3f const val GHOST_ALPHA: Float = 0.3f

View File

@@ -142,6 +142,17 @@ class RescheduleViewModel @Inject constructor(
*/ */
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow() 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 * 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 * of the same recurring event landing inside that window would each compute
@@ -235,6 +246,7 @@ class RescheduleViewModel @Inject constructor(
fun undo(undo: MoveUndo) { fun undo(undo: MoveUndo) {
if (busy) return if (busy) return
busy = true busy = true
_undoStarted.value += 1
viewModelScope.launch { viewModelScope.launch {
_outcome.value = try { _outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored) 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.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -20,14 +21,49 @@ class MonthRowGeometry(
val days: List<LocalDate>, val days: List<LocalDate>,
/** The row's day-column box — the space chip offsets are measured in. */ /** The row's day-column box — the space chip offsets are measured in. */
val cell: LayoutCoordinates, val cell: LayoutCoordinates,
/** The lane band inside that box, where the chips themselves are seated. */
val band: LayoutCoordinates?,
val columnWidthPx: Float, val columnWidthPx: Float,
val laneHeightPx: Float,
val laneCount: Int,
/** /**
* Whether the columns are laid out right-to-left. Pointer coordinates are * 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 * never mirrored, but the grid is, so the leftmost column is the *last* day
* in Arabic. * in Arabic.
*/ */
val isRtl: Boolean, 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. */ /** A chip in flight, in root coordinates so it can be drawn in an overlay. */
data class MonthChipDrag( data class MonthChipDrag(
@@ -105,6 +141,13 @@ class MonthDragController {
var isDragging: Boolean by mutableStateOf(false) var isDragging: Boolean by mutableStateOf(false)
private set 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 event: EventInstance? = null
private var grabDate: LocalDate? = null private var grabDate: LocalDate? = null
private var grab = Offset.Zero private var grab = Offset.Zero
@@ -164,20 +207,46 @@ class MonthDragController {
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean { fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
val landed = settling?.takeIf { settledGhost } ?: return false val landed = settling?.takeIf { settledGhost } ?: return false
if (days == null || landed.targetDate !in days) 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 * Ask the row keyed [token] whether it now seats the settled chip, and take
* each adjoining month, so both copies of the landing row offer one: the one * its answer. The continuous style can show the same week twice, once in each
* nearest where the chip was dropped is the one the finger was over. * 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 landed = settling ?: return
val target = landed.targetDate ?: return
val at = rows[token]?.seatOf(landed.event, target) ?: return
val current = settledInRoot val current = settledInRoot
val closer = current == null || val closer = current == null ||
abs(topLeftInRoot.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y) abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
if (closer) settledInRoot = topLeftInRoot 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 liftedInstanceId = landed.event.instanceId
settledGhost = true settledGhost = true
undoable = settling
return MonthChipDrop(landed.event, landed.grabDate, target) 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.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue 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.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.moveInFlight 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.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS 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 // overlay so it is free of the week row's clip and, in the scrolling
// styles, of the list viewport's. // styles, of the list viewport's.
val chipDrag = rememberMonthDragController() 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( Box(
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
@@ -1860,6 +1866,7 @@ private fun MonthWeekRow(
val dragController = LocalMonthDrag.current val dragController = LocalMonthDrag.current
val rowToken = remember { Any() } val rowToken = remember { Any() }
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) } val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val cellCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val density = LocalDensity.current val density = LocalDensity.current
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() } val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@@ -1867,36 +1874,35 @@ private fun MonthWeekRow(
DisposableEffect(rowToken, dragController) { DisposableEffect(rowToken, dragController) {
onDispose { dragController?.removeRow(rowToken) } onDispose { dragController?.removeRow(rowToken) }
} }
// Where this row seats a drop that landed on one of its days, once the // Republished on layout *and* on every recomposition: the coordinates only
// re-read arrives — the copy in flight glides onto it. Null until the grid // change on the former, but what the row draws — which the controller reads
// actually holds the moved event: before the re-read the week still shows it // to find a moved chip's seat — changes on the latter, and a re-read that
// on the day it came from, and a chip that lands in the "+N" overflow gets no // moves an event doesn't move the row it lands in.
// seat at all. val publish = {
val landed = dragController?.settling val cell = cellCoordinates[0]?.takeIf { it.isAttached }
val seat = if (landed == null) { if (dragController != null && cell != null) {
null dragController.putRow(
} else { rowToken,
remember(week, landed) { MonthRowGeometry(
val target = landed.targetDate ?: return@remember null days = week.days,
val col = week.days.indexOf(target).takeIf { it >= 0 } ?: return@remember null cell = cell,
val lane = (0 until MAX_EVENT_ROWS).firstOrNull { lane -> band = bandCoordinates[0],
val chip = week.chipAt(col, lane, MAX_EVENT_ROWS) columnWidthPx = cell.size.width / 7f,
chip != null && dragController.isSettledChip(chip, listOf(target)) laneHeightPx = rowHeightPx,
} ?: return@remember null laneCount = MAX_EVENT_ROWS,
col to lane isRtl = isRtl,
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
),
)
} }
} }
LaunchedEffect(seat, dragController) { SideEffect { publish() }
val (col, lane) = seat ?: return@LaunchedEffect // Once the grid holds the settled chip, tell the controller where this row
val band = bandCoordinates[0]?.takeIf { it.isAttached } ?: return@LaunchedEffect // has seated it, so the copy in flight can glide onto it. Keyed on the week
val origin = band.positionInRoot() // because that is what changes when the re-read lands: until then the row
val column = if (isRtl) week.days.lastIndex - col else col // still draws the event on the day it came from and answers nothing.
dragController?.noteSettled( LaunchedEffect(week, dragController?.settling) {
Offset( dragController?.noteSettled(rowToken)
x = origin.x + column * (band.size.width / 7f),
y = origin.y + lane * rowHeightPx,
),
)
} }
Row(modifier) { Row(modifier) {
@@ -1916,15 +1922,8 @@ private fun MonthWeekRow(
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.onGloballyPositioned { coords -> .onGloballyPositioned { coords ->
dragController?.putRow( cellCoordinates[0] = coords
rowToken, publish()
MonthRowGeometry(
days = week.days,
cell = coords,
columnWidthPx = coords.size.width / 7f,
isRtl = isRtl,
),
)
} }
.then( .then(
monthChipDragModifier( monthChipDragModifier(
@@ -2000,7 +1999,10 @@ private fun MonthWeekRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
.onGloballyPositioned { bandCoordinates[0] = it } .onGloballyPositioned {
bandCoordinates[0] = it
publish()
}
// A dragged chip travels to another row, so the clip has // A dragged chip travels to another row, so the clip has
// to yield for it exactly as it does for a morph. // to yield for it exactly as it does for a morph.
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()), .then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),