diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 8052f2c..e73445d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -317,6 +317,7 @@ fun CalendarHost( movableCalendarIds = movableCalendarIds, move = reschedule::move, inFlight = reschedule.inFlight, + undoStarted = reschedule.undoStarted, edit = onEditEvent, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt index cfae0b3..62149c1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt @@ -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, + /** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */ + val undoStarted: StateFlow, /** 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 { 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index 95af55b..1300752 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -142,6 +142,17 @@ class RescheduleViewModel @Inject constructor( */ val inFlight: StateFlow = _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 = _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) 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 8716d47..ee42ad3 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 @@ -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, /** 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?): 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) } 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 37070c5..59fae40 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 @@ -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(1) } + val cellCoordinates = remember { arrayOfNulls(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()),