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 07349082..d3b8bfb1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -485,9 +485,8 @@ fun CalendarHost( movableCalendarIds = movableCalendarIds, dragEnabled = dragToReschedule, move = { reschedule.move(it) }, - inFlight = reschedule.inFlight, + drops = reschedule.drops, undoStarted = reschedule.undoStarted, - abandoned = reschedule.abandoned, edit = onEditEvent, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt index f827dc99..542222aa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -90,6 +90,9 @@ fun EventMoveHost( MoveOutcome.Undone -> stringResource(R.string.event_move_undone) MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied) MoveOutcome.Gone -> stringResource(R.string.event_move_gone) + MoveOutcome.Stale -> stringResource(R.string.event_move_stale) + MoveOutcome.Skipped -> stringResource(R.string.event_move_skipped) + MoveOutcome.QueueFull -> stringResource(R.string.event_move_queue_full) MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end) MoveOutcome.BlockedCancelledSeries -> stringResource(R.string.event_move_blocked_cancelled_series) MoveOutcome.Failed -> stringResource(R.string.event_move_failed) 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 92b8eae1..8eaa5b6c 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 @@ -1,12 +1,15 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.core.animateFloatAsState +import kotlinx.coroutines.delay +import androidx.compose.animation.core.tween +import androidx.compose.runtime.rememberUpdatedState 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource @@ -16,6 +19,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import java.util.concurrent.atomic.AtomicLong /** * Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by @@ -37,24 +41,18 @@ class EventMoveScope( val dragEnabled: Boolean, /** * Hand a drop to the writer. Returns nothing on purpose: a drop is answered - * before its write resolves, so [abandoned] — not a return value — is what - * tells a held copy that nothing landed. + * before its write resolves, so [drops] — not a return value — is what tells + * a held copy whether anything landed. */ val move: (MoveRequest) -> Unit, /** - * True while a dropped event is being written, including the time its scope - * dialog is up. A flow rather than a value so this scope stays the same - * object across a move — every visible block reads it as a composition local. + * Each drop's progress by its token — see `RescheduleViewModel.drops`. A flow + * rather than a value so this scope stays the same object across a move — + * every visible block reads it as a composition local. */ - val inFlight: StateFlow, - /** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */ - val undoStarted: StateFlow, - /** - * Ticks when a drop ends with nothing landing — see - * `RescheduleViewModel.abandoned`. [move] answers before the write is - * resolved, so this is what tells a held copy to stop waiting. - */ - val abandoned: StateFlow, + val drops: StateFlow, + /** The undo write that just began — 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, ) { @@ -63,30 +61,42 @@ class EventMoveScope( val LocalEventMove = compositionLocalOf { null } -private val NEVER_IN_FLIGHT = MutableStateFlow(false) +private val moveTokens = AtomicLong() -private val NEVER_UNDONE = MutableStateFlow(0) +/** A fresh identity for one drop or undo, unique for the process. */ +fun nextMoveToken(): Long = moveTokens.incrementAndGet() -private val NEVER_ABANDONED = MutableStateFlow(0) +/** How many abandoned drops [DropStates] remembers — far more than can be on screen at once. */ +private const val ABANDONED_KEPT = 32 -/** - * 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. - */ -@Composable -fun moveInFlight(): Boolean { - val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT - return flow.collectAsStateWithLifecycle().value +/** Per-drop progress, by token — see `RescheduleViewModel.drops`. */ +@Immutable +data class DropStates( + val pending: Set = emptySet(), + val abandoned: Set = emptySet(), +) { + fun started(token: Long): DropStates = copy(pending = pending + token) + + fun ended(token: Long, landed: Boolean): DropStates = copy( + pending = pending - token, + // Insertion-ordered, so the oldest go first. + abandoned = if (landed) abandoned else (abandoned + token).let { kept -> + if (kept.size > ABANDONED_KEPT) kept.drop(kept.size - ABANDONED_KEPT).toSet() else kept + }, + ) } +private val NO_DROPS = MutableStateFlow(DropStates()) + +private val NEVER_UNDONE = MutableStateFlow(null) + /** - * How many drops have ended with nothing landing. Read as a plain count so a - * drag overlay can capture it at the moment of the drop and tell, later, whether - * *its own* drop was one of them. + * Every drop's progress — empty wherever moving is off. A drag overlay keeps + * the token of its own drop and holds its copy while that token is pending. */ @Composable -fun abandonedMoves(): Int { - val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED +fun dropStates(): DropStates { + val flow = LocalEventMove.current?.drops ?: NO_DROPS return flow.collectAsStateWithLifecycle().value } @@ -95,17 +105,53 @@ fun abandonedMoves(): Int { * this composable came on screen. */ @Composable -fun OnUndoStarted(onUndo: () -> Unit) { +fun OnUndoStarted(onUndo: (UndoStart) -> 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() + val start by flow.collectAsStateWithLifecycle() + var seen by remember { mutableStateOf(start) } + LaunchedEffect(start) { + val started = start + if (started == null || started == seen) return@LaunchedEffect + seen = started + onUndo(started) } } +/** + * The life of a drop parked by a newer pickup (#494): held while its [token] + * is pending, then a beat after the grid [seated] it — or [timeoutMillis] if it + * never does — handed over and faded, then released. Returns the copy's alpha. + * An abandoned drop skips the wait; nothing is coming to seat it. + */ +@Composable +fun rememberHeldSettle( + token: Long, + seated: Boolean, + timeoutMillis: Long, + onHandOver: () -> Unit, + onRelease: () -> Unit, +): Float { + val drops = dropStates() + val pending = token in drops.pending + val abandoned = token in drops.abandoned + val handOver by rememberUpdatedState(onHandOver) + val release by rememberUpdatedState(onRelease) + var fading by remember(token) { mutableStateOf(false) } + LaunchedEffect(token, pending, abandoned, seated) { + if (pending) return@LaunchedEffect + if (!abandoned) delay(if (seated) SETTLE_GRACE_MILLIS else timeoutMillis) + fading = true + handOver() + delay(SETTLE_FADE_MILLIS.toLong()) + release() + } + return animateFloatAsState( + targetValue = if (fading) 0f else 1f, + animationSpec = tween(SETTLE_FADE_MILLIS), + label = "held-settle", + ).value +} + /** 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 dad5cb1f..5ef83d4a 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 @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate @@ -40,12 +41,21 @@ import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant +import kotlin.time.TimeMark +import kotlin.time.TimeSource import javax.inject.Inject private const val MILLIS_PER_MINUTE = 60_000 private const val MINUTES_PER_DAY = 24 * 60 +/** How many drops may wait behind the one in flight before more are refused. */ +private const val MAX_QUEUED = 8 + +/** How long after a write its event's drawn times may still be the old ones. */ +private val RECENT_WRITE = 3.seconds + /** Where a dragged event should land. */ sealed interface MoveTarget { /** A new start instant — a timeline drag, which moves time and day at once. */ @@ -74,6 +84,10 @@ data class MoveRequest( val beginMillis: Long, val endMillis: Long, val target: MoveTarget, + /** Whether the dragged occurrence is all-day, which decides the zone its day is read in. */ + val isAllDay: Boolean, + /** The drop's own identity, from [nextMoveToken], which the view holding its copy waits on. */ + val token: Long, ) /** @@ -92,8 +106,13 @@ data class MoveUndo( val moved: EventForm, /** The form as it stood before the move. */ val restored: EventForm, + /** The token of the drop this undoes, so the view that drew it carries the right chip back. */ + val dropToken: Long, ) +/** An undo write that just began: its own [token], and the [dropToken] of the drop it undoes. */ +data class UndoStart(val token: Long, val dropToken: Long) + sealed interface MoveOutcome { /** * Written. [startMillis] is where the dragged occurrence now begins, for the @@ -115,6 +134,19 @@ sealed interface MoveOutcome { /** The event vanished (sync, another device) between the read and the write. */ data object Gone : MoveOutcome + /** Drops that waited behind a scope dialog, cancelled rather than run after it (#494). */ + data object Skipped : MoveOutcome + + /** More drops waiting than the queue holds; this one was not taken. */ + data object QueueFull : MoveOutcome + + /** + * The drop's recurring occurrence may no longer be where it was picked up — + * its series was just written — and can't be followed there, so it is not + * written (#494). + */ + data object Stale : MoveOutcome + /** The drop would push the series past its own `UNTIL`, generating nothing. */ data object BlockedSeriesEnd : MoveOutcome @@ -152,36 +184,32 @@ class RescheduleViewModel @Inject constructor( private val _inFlight = MutableStateFlow(false) - /** - * True from the moment a drop is accepted until its write settles — the - * window the dropped block holds its landing position for. - */ + /** True while any drop or undo runs or waits — what the confirmation chip holds for. */ val inFlight: StateFlow = _inFlight.asStateFlow() - private val _abandoned = MutableStateFlow(0) + private val _drops = MutableStateFlow(DropStates()) /** - * Ticks when a drop ends with nothing on the target day: refused outright, - * refused before the write, written and failed, or its scope dialog - * dismissed. [move] answers before any of that is known, so without this - * signal the view that drew the drop held its copy out for the full settle - * timeout waiting for a grid that was never going to draw it (#253). + * Each drop's own state, by its [MoveRequest.token]: pending from the moment + * it is accepted until its write settles, and abandoned once it ends with + * nothing on the target day — refused, failed, or its scope dialog dismissed. + * Per drop, so a queued drop's held copy waits on its own write and is never + * let go by another drop's failure (#253, #494). */ - val abandoned: StateFlow = _abandoned.asStateFlow() + val drops: StateFlow = _drops.asStateFlow() - private val _undoStarted = MutableStateFlow(0) + private val _undoStarted = MutableStateFlow(null) /** - * Ticks the moment an undo write begins, before the provider has anything to + * An undo write the moment it begins, before the provider has anything to * re-read, so the view that drew the drop can carry its chip back rather than - * let it reappear on the old day. + * let it reappear on the old day. Its fate then reads off [drops]. */ - val undoStarted: StateFlow = _undoStarted.asStateFlow() + val undoStarted: StateFlow = _undoStarted.asStateFlow() /** - * Set from the moment a drop is accepted until its write settles. Two drops - * of the same recurring event inside that window would compute their shift - * from the same pre-move occurrence, and the shifts would compound. + * Set while a drop is being prepared, waits on its scope dialog, or is + * written. Drops arriving in that window wait in [queue] and run in order. */ private var busy = false set(value) { @@ -189,6 +217,22 @@ class RescheduleViewModel @Inject constructor( _inFlight.value = value } + /** Whether a move has been announced since the pipeline was last idle — see [report]. */ + private var movedThisRun = false + + /** Outcomes waiting for the chip, shown one after another as each is consumed. */ + private val notices = ArrayDeque() + + /** + * When each event's series (by id) or single occurrence (by id and begin) + * was last written. The grid draws the old times until it re-reads, so a + * drop on a recurring event written moments ago may carry stale times (#494). + */ + private val written = mutableMapOf, TimeMark>() + + /** Drops that arrived while another was in flight, oldest first (#494). */ + private val queue = ArrayDeque() + /** * The calendars whose events may be dragged. Nothing below the UI guards * this, so a block outside this set registers no drag gesture at all. @@ -219,38 +263,50 @@ class RescheduleViewModel @Inject constructor( } /** - * Take a drop, unless one is already being written. False means nothing will - * be written; [abandoned] ticks for that refusal too, so a held copy is let - * go by the one signal rather than by every caller remembering to. + * Take a drop: run it now, or queue it behind the ones in flight. False means + * nothing will be written — the queue is full — and the drop is marked + * abandoned in [drops], so its held copy is let go by the one signal rather + * than by every caller remembering to. * * The Boolean is diagnostic — the tests read it, [EventMoveScope] does not - * hand it on. A caller that gated on it would be back to releasing its copy - * from two places, and it answers before the write is resolved anyway. + * hand it on. It answers before the write is resolved anyway. */ fun move(request: MoveRequest): Boolean { - if (busy || _scopePrompt.value != null) { - _abandoned.value += 1 + if (queue.size >= MAX_QUEUED) { + _drops.update { it.ended(request.token, landed = false) } + report(MoveOutcome.QueueFull) return false } + if (_scopePrompt.value != null) { + _drops.update { it.ended(request.token, landed = false) } + report(MoveOutcome.Skipped) + return false + } + _drops.update { it.started(request.token) } + if (busy) queue.addLast(request) else run(request) + return true + } + + private fun run(request: MoveRequest) { busy = true viewModelScope.launch { val prepared = prepare(request) if (prepared == null) { - _abandoned.value += 1 - busy = false + settle(request.token, landed = false) return@launch } // A cancelled series only moves as a whole (#388). val seriesOnly = !prepared.original.canSaveApartFromSeries() if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) { - write(prepared, RecurringWriteScope.AllEvents) - busy = false + settle(request.token, write(prepared, RecurringWriteScope.AllEvents)) } else if (seriesOnly) { - _outcome.value = MoveOutcome.BlockedCancelledSeries - _abandoned.value += 1 - busy = false + report(MoveOutcome.BlockedCancelledSeries) + settle(request.token, landed = false) } else { - // Still busy: the scope dialog is now the thing in flight. + // Still busy: the scope dialog is now the thing in flight. Drops + // waiting behind it are cancelled, not held behind a question + // about another event. + skipQueued() pending = prepared _scopePrompt.value = MoveScopePrompt( occurrenceOnly = !prepared.canRealign, @@ -258,7 +314,6 @@ class RescheduleViewModel @Inject constructor( ) } } - return true } /** Answer the scope dialog. */ @@ -269,35 +324,92 @@ class RescheduleViewModel @Inject constructor( pending = null _scopePrompt.value = null viewModelScope.launch { - write(prepared, scope) - busy = false + settle(prepared.request.token, write(prepared, scope)) } } /** Dismiss the scope dialog without writing. */ fun cancelScope() { + val prepared = pending ?: return pending = null - busy = false _scopePrompt.value = null - _abandoned.value += 1 + settle(prepared.request.token, landed = false) + } + + /** End the drop [token], then run the next queued one, if any. */ + private fun settle(token: Long, landed: Boolean) { + _drops.update { it.ended(token, landed) } + // Straight on to the next, so busy never reads false between two drops. + val next = queue.removeFirstOrNull() + if (next != null) { + run(next) + } else { + busy = false + movedThisRun = false + } + } + + private fun skipQueued() { + if (queue.isEmpty()) return + val skipped = queue.map { it.token } + queue.clear() + _drops.update { states -> skipped.fold(states) { acc, token -> acc.ended(token, landed = false) } } + report(MoveOutcome.Skipped) + } + + /** + * Queue [outcome] for the chip. While drops run back to back only the first + * move is announced, so its Undo stays on offer; every failure gets its own + * turn after it, since the grid shows what moved but only the chip says what + * didn't. A later write to the announced move's event takes that Undo away, + * as putting the event back would undo the later move too. + */ + private fun report(outcome: MoveOutcome, eventId: Long? = null) { + if (outcome is MoveOutcome.Moved) { + if (movedThisRun) { + dropUndoFor(eventId) + return + } + movedThisRun = true + } else if (outcome == (notices.lastOrNull() ?: _outcome.value)) { + return + } + if (_outcome.value == null) _outcome.value = outcome else notices.addLast(outcome) + } + + private fun dropUndoFor(eventId: Long?) { + fun MoveOutcome.withoutUndo(): MoveOutcome = + if (this is MoveOutcome.Moved && undo?.eventId == eventId) copy(undo = null) else this + _outcome.value = _outcome.value?.withoutUndo() + val rest = notices.map { it.withoutUndo() } + notices.clear() + notices.addAll(rest) + } + + private fun markWritten(eventId: Long, beginMillis: Long?) { + written[eventId to beginMillis] = TimeSource.Monotonic.markNow() + } + + private fun writtenRecently(eventId: Long, beginMillis: Long): Boolean { + written.entries.removeAll { it.value.elapsedNow() > RECENT_WRITE } + return (eventId to null) in written || (eventId to beginMillis) in written } /** * Put a completed move back where it came from. The outcome stands until the * inverse write reports back, so the one chip changes what it says rather * than closing and reopening. False when another write is already running — - * the chip hides its action for that window, so this is the backstop. - * - * [abandoned] ticks once an inverse that *started* fails to land: [undoStarted] - * has sent the chip back on its journey by then and nothing else will seat it. - * A refusal ticks nothing — it launched no journey of its own, and the counter - * is global, so a tick here would release some other drop's held copy before - * the grid has drawn it. + * the chip hides its action for that window, so this is the backstop. An + * undo is a journey of its own: [undoStarted] names its token, and [drops] + * reports whether it landed. */ fun undo(undo: MoveUndo): Boolean { if (busy) return false + val token = nextMoveToken() + _drops.update { it.started(token) } busy = true - _undoStarted.value += 1 + _undoStarted.value = UndoStart(token, undo.dropToken) + markWritten(undo.eventId, null) viewModelScope.launch { _outcome.value = try { repository.updateEvent(undo.eventId, undo.moved, undo.restored) @@ -311,33 +423,40 @@ class RescheduleViewModel @Inject constructor( } catch (e: Exception) { MoveOutcome.Failed } - // Read off the outcome, the way [write] does — no second copy of the - // same fact to drift from it. - if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1 - busy = false + settle(token, landed = _outcome.value is MoveOutcome.Undone) } return true } - /** Clear the outcome once the screen has shown it. */ + /** Clear the outcome once the screen has shown it, bringing on the next one waiting. */ fun consumeOutcome() { - _outcome.value = null + _outcome.value = notices.removeFirstOrNull() } - private suspend fun prepare(request: MoveRequest): PreparedMove? { + private suspend fun prepare(dropped: MoveRequest): PreparedMove? { + val zone = zoneSource.current() val detail = try { - repository.eventDetail(request.eventId) + repository.eventDetail(dropped.eventId) } catch (e: CancellationException) { throw e } catch (e: NoSuchEventException) { - _outcome.value = MoveOutcome.Gone + report(MoveOutcome.Gone) return null } catch (e: Exception) { - _outcome.value = MoveOutcome.Failed + report(MoveOutcome.Failed) return null } + // The drop carries the times the grid drew, which an earlier write may + // have moved on since (#494). + val request = if (detail.rrule == null || detail.isException) { + followed(dropped, detail, zone) + } else if (writtenRecently(dropped.eventId, dropped.beginMillis)) { + report(MoveOutcome.Stale) + return null + } else { + dropped + } - val zone = zoneSource.current() val original = detail.toEditForm(request.beginMillis, request.endMillis, zone) val shifted = when (val target = request.target) { is MoveTarget.Start -> original.shiftedTo(target.instant, zone) @@ -350,7 +469,7 @@ class RescheduleViewModel @Inject constructor( // The UNTIL check is deferred to the write, which knows how far the move // reaches and so which date has to clear it. if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) { - _outcome.value = MoveOutcome.Failed + report(MoveOutcome.Failed) return null } @@ -395,14 +514,14 @@ class RescheduleViewModel @Inject constructor( ) } - private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) { + /** Write [prepared] in [scope]; true when it landed. */ + private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope): Boolean { val request = prepared.request if (endsBeforeItStarts(prepared, scope)) { - _outcome.value = MoveOutcome.BlockedSeriesEnd - _abandoned.value += 1 - return + report(MoveOutcome.BlockedSeriesEnd) + return false } - _outcome.value = try { + val outcome = try { when { !prepared.isRecurring || scope == RecurringWriteScope.AllEvents -> repository.updateEvent(request.eventId, prepared.original, prepared.updated) @@ -437,9 +556,37 @@ class RescheduleViewModel @Inject constructor( } catch (e: Exception) { MoveOutcome.Failed } - // Nothing landed, so the copy the view is holding has nothing to fade - // into — let it go now rather than at the end of the settle timeout. - if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1 + if (outcome is MoveOutcome.Moved) { + markWritten(request.eventId, request.beginMillis.takeIf { scope == RecurringWriteScope.ThisEvent && prepared.isRecurring }) + } + report(outcome, request.eventId) + return outcome is MoveOutcome.Moved + } + + /** + * A one-off [dropped] rebased onto where its event now is, so the last drop + * wins: absolute targets stand as they are, and a whole-day shift is re-aimed + * at the day it was dropped on. Unchanged when the event hasn't moved. + */ + private fun followed(dropped: MoveRequest, detail: EventDetail, zone: TimeZone): MoveRequest { + val now = detail.instance + val target = when (val target = dropped.target) { + is MoveTarget.ByDays -> { + // Each day read the way its own kind anchors it: all-day at UTC. + val aimed = Instant.fromEpochMilliseconds(dropped.beginMillis) + .toLocalDateTime(if (dropped.isAllDay) TimeZone.UTC else zone).date + .plus(target.days, DateTimeUnit.DAY) + val from = now.start.toLocalDateTime(if (now.isAllDay) TimeZone.UTC else zone).date + MoveTarget.ByDays((aimed.toEpochDays() - from.toEpochDays()).toInt()) + } + else -> target + } + return dropped.copy( + beginMillis = now.start.toEpochMilliseconds(), + endMillis = now.end.toEpochMilliseconds(), + target = target, + isAllDay = now.isAllDay, + ) } /** @@ -506,6 +653,7 @@ class RescheduleViewModel @Inject constructor( eventId = prepared.request.eventId, moved = prepared.updated, restored = prepared.original, + dropToken = prepared.request.token, ) } else { null diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 9ed6cf4d..33eb4050 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -60,7 +61,6 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.atStartOfDayIn @@ -139,9 +139,17 @@ data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGr sealed interface StripDrop { val event: EventInstance - data class Days(override val event: EventInstance, val days: Int) : StripDrop + /** The drop's identity, which its held copy waits on. */ + val token: Long - data class Timed(override val event: EventInstance, val start: Instant, val end: Instant) : StripDrop + data class Days(override val event: EventInstance, val days: Int, override val token: Long) : StripDrop + + data class Timed( + override val event: EventInstance, + val start: Instant, + val end: Instant, + override val token: Long, + ) : StripDrop } /** The event a long press left selected (#141). */ @@ -297,7 +305,9 @@ data class TimelineDrop( val date: LocalDate, val startMin: Int, /** Minutes from the event's start to the dragged block's top edge. */ - val clipOffsetMin: Int = 0, + val clipOffsetMin: Int, + /** The drop's identity, which its held copy waits on. */ + val token: Long, ) /** The id a drafted block's stand-in event carries, which no provider row has (#153). */ @@ -340,8 +350,37 @@ internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): /** Which end of an event a resize handle moves (#141). */ enum class ResizeEdge { Top, Bottom } +/** A drop parked by a newer pickup while its write still runs — see [TimelineDragController.held]. */ +data class HeldDrop( + val copy: TimelineDrag, + val token: Long, + val instanceId: Long, + /** The event row and start the drop came from, as [TimelineDragController.ghosts] matches them. */ + val origin: Pair, + /** A resize's new end, which has to have landed too before the drop counts as seated. */ + val end: Instant?, + /** Whether the grid now draws the drop at its landing slot. */ + val seated: Boolean = false, + val handedOver: Boolean = false, +) { + fun ghosts(block: TimedBlock): Boolean = block.event.instanceId == instanceId || + (block.event.eventId == origin.first && block.event.start == origin.second) +} + +/** A strip drop parked by a newer one while its write still runs — see [TimelineDragController.heldStrips]. */ +data class HeldStrip(val drag: StripDrag, val token: Long, val seated: Boolean = false) + +/** An event's new start and end, as a resize leaves them. */ +data class ResizedSpan(val start: Instant, val end: Instant) + /** Where a finished resize asks its event to begin and end. */ -data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant) +data class TimelineResizeDrop( + val event: EventInstance, + val start: Instant, + val end: Instant, + /** The drop's identity, which its held copy waits on. */ + val token: Long, +) /** * [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s @@ -352,17 +391,16 @@ data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val * it was drawn (see [repicked]). An end takes a pass the grid draws at the drop * point, so the block settles under the finger. */ -internal fun resizedDrop( +internal fun resizedSpan( event: EventInstance, edge: ResizeEdge?, date: LocalDate, startMin: Int, spanMin: Int, zone: TimeZone, -): TimelineResizeDrop { +): ResizedSpan { val drawn = wallClockSpan(event, zone) - return TimelineResizeDrop( - event = event, + return ResizedSpan( start = if (edge == ResizeEdge.Top) { event.start.toFormTime(zone).repicked( wallClockTime(date, startMin), @@ -513,6 +551,14 @@ class TimelineDragController { */ private var settledOrigin: Pair? by mutableStateOf(null) + /** + * Earlier drops still being written when another block was picked up. They + * keep their copy and their ghost until their own token settles, rather than + * vanishing and flashing back at the old slot (#494). + */ + var held: List by mutableStateOf(emptyList()) + private set + /** How long a block drafted on an empty slot starts out, in minutes (#153). */ var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION @@ -611,14 +657,8 @@ class TimelineDragController { var settlingSelected: Boolean = false private set - /** - * The abandoned-move counter, read at the drop so the mark can't be a - * composition behind the tick it is compared against. - */ - var abandonedTicks: StateFlow? = null - - /** What [abandonedTicks] stood at when the settling drop was let go. */ - var settledAbandonedMark: Int = 0 + /** The token of the drop [settling] waits on. */ + var settledToken: Long = 0L private set private var source: TimedBlock? = null @@ -661,8 +701,12 @@ class TimelineDragController { var stripSeated: Boolean by mutableStateOf(false) private set - /** What [abandonedTicks] stood at when [stripSettling] was let go. */ - var stripAbandonedMark: Int = 0 + /** Strip drops parked by a newer strip drop while their writes still run (#494). */ + var heldStrips: List by mutableStateOf(emptyList()) + private set + + /** The token of the drop [stripSettling] waits on. */ + var stripToken: Long = 0L private set /** The shown day the finger picked the strip bar up on. */ @@ -687,7 +731,7 @@ class TimelineDragController { dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO) dragKept = zone.repeatedHourOffsetAt(block.event.start) this.titleLines = titleLines - settling = null + parkSettling() isDragging = true liftedInstanceId = block.event.instanceId grab = pointerInRoot - blockInRoot @@ -726,7 +770,7 @@ class TimelineDragController { dragDuration = draftMinutes.minutes dragKept = null titleLines = 1 - settling = null + parkSettling() isDragging = true liftedInstanceId = event.instanceId grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f) @@ -749,16 +793,18 @@ class TimelineDragController { val zone = dragZone cancel() if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) { - settle(landed) - val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone) - return StripDrop.Timed(held.event, start, start + draftMinutes.minutes) + val token = settle(landed) + val start = dropStartInstant(landed.event, landed.date, landed.startMin, clipOffsetMin = 0, zone) + return StripDrop.Timed(held.event, start, start + draftMinutes.minutes, token) } if (held.overGrid || held.dayShift == 0) return null + // One still settling keeps its place on the strip until its own write lands (#494). + stripSettling?.let { heldStrips = heldStrips + HeldStrip(it, stripToken, stripSeated) } stripSettling = held stripSeated = false - stripAbandonedMark = abandonedTicks?.value ?: 0 + stripToken = nextMoveToken() drops++ - return StripDrop.Days(held.event, held.dayShift) + return StripDrop.Days(held.event, held.dayShift, stripToken) } /** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */ @@ -772,6 +818,21 @@ class TimelineDragController { if (stripSettling != null) stripSeated = true } + /** Note that the strip draws the parked strip drop [token] at its new days. */ + fun seatHeldStrip(token: Long) { + if (heldStrips.any { it.token == token && !it.seated }) { + heldStrips = heldStrips.map { if (it.token == token) it.copy(seated = true) else it } + } + } + + /** Let the parked strip drop [token] go. */ + fun releaseHeldStrip(token: Long) { + heldStrips = heldStrips.filterNot { it.token == token } + } + + /** Every strip drop still shown at its new days: the parked ones, then the latest. */ + fun stripPreviews(): List = heldStrips.map { it.drag } + listOfNotNull(stripDrag ?: stripSettling) + fun move(pointerInRoot: Offset) { pointer = pointerInRoot if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true @@ -806,12 +867,36 @@ class TimelineDragController { * an event that crosses midnight (#253). */ fun ghosts(block: TimedBlock): Boolean { + if (held.any { !it.handedOver && it.ghosts(block) }) return true if (liftedInstanceId == null) return false if (block.event.instanceId == liftedInstanceId) return true val (eventId, start) = settledOrigin ?: return false return block.event.eventId == eventId && block.event.start == start } + /** Move a still-settling drop to [held] before a new pickup takes the slot. */ + private fun parkSettling() { + val landed = settling + val origin = settledOrigin + if (landed != null && origin != null && !landed.event.isDraft) { + held = held + HeldDrop(landed, settledToken, landed.event.instanceId, origin, settledEnd, settledOnGrid) + } + settling = null + settledOnGrid = false + settledOrigin = null + settledEnd = null + } + + /** Stop ghosting the held drop [token]'s source while its copy fades. */ + fun handOverHeld(token: Long) { + held = held.map { if (it.token == token) it.copy(handedOver = true) else it } + } + + /** Drop the held copy [token] once it has faded. */ + fun releaseHeld(token: Long) { + held = held.filterNot { it.token == token } + } + /** * 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 @@ -819,16 +904,23 @@ class TimelineDragController { * blank title matches nothing: every untitled event has one (#253). */ fun noteGrid(date: LocalDate, blocks: List) { + if (held.any { !it.seated && drawsDrop(it.copy, it.end, date, blocks) }) { + held = held.map { if (!it.seated && drawsDrop(it.copy, it.end, date, blocks)) it.copy(seated = true) else it } + } val landed = settling ?: return if (settledOnGrid) return + settledOnGrid = drawsDrop(landed, settledEnd, date, blocks) + } + + /** Whether [blocks], [date]'s column, draws the drop [landed] where it landed. */ + private fun drawsDrop(landed: TimelineDrag, end: Instant?, date: LocalDate, blocks: List): Boolean { // Where this day would draw it, which for a drop clipped at midnight is // the day's own start rather than the edge the finger held (#253) — and // which for a tail dragged clear of its day is the day before it. val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt() val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset) - .firstOrNull() ?: return - val end = settledEnd - settledOnGrid = blocks.any { block -> + .firstOrNull() ?: return false + return blocks.any { block -> block.startMin == slice.startMin && // A resize may keep its start, so its end has to have landed too. (end == null || block.event.end == end) && @@ -855,12 +947,15 @@ class TimelineDragController { if (landed == null || landed.slot == origin) return null // A draft dropped on a day off screen would vanish, so it stays where it was (#153). if (landed.edgeOnly && landed.event.isDraft) return null - settle(landed) - return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset) + val token = settle(landed) + return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset, token) } - /** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */ - private fun settle(landed: TimelineDrag, end: Instant? = null) { + /** + * Keep [landed]'s copy on the target as [settling] until [release]; [end] is a + * resize's. Returns the drop's fresh token. + */ + private fun settle(landed: TimelineDrag, end: Instant? = null): Long { // Landed on midnight to midnight, the event moves to the all-day strip (#296). val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 && landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 && @@ -873,9 +968,10 @@ class TimelineDragController { settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed liftedInstanceId = landed.event.instanceId settledOrigin = landed.event.eventId to landed.event.start - settledAbandonedMark = abandonedTicks?.value ?: 0 + settledToken = nextMoveToken() settledEnd = end drops++ + return settledToken } /** @@ -895,7 +991,7 @@ class TimelineDragController { resize = session clipOffsetMin = 0 this.titleLines = titleLines - settling = null + parkSettling() isDragging = true pointerMoved = true liftedInstanceId = block.event.instanceId @@ -926,10 +1022,10 @@ class TimelineDragController { val event = source?.event ?: return null val edge = resize?.edge if (edge != null) { - val drop = resizedDrop(event, edge, date, startMin, spanMin, zone) + val drop = resizedSpan(event, edge, date, startMin, spanMin, zone) return event.copy(start = drop.start, end = drop.end) } - val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone) + val start = dropStartInstant(event, date, startMin, clipOffsetMin, zone) return event.copy(start = start, end = start + dragDuration) } @@ -943,9 +1039,8 @@ class TimelineDragController { val edge = resize?.edge cancel() if (landed == null || landed.slot == origin) return null - val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone) - settle(landed, end = drop.end) - return drop + val span = resizedSpan(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone) + return TimelineResizeDrop(landed.event, span.start, span.end, settle(landed, end = span.end)) } /** @@ -1199,7 +1294,6 @@ fun rememberTimelineDragController(): TimelineDragController { controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() } controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() } controller.slopPx = LocalViewConfiguration.current.touchSlop - controller.abandonedTicks = LocalEventMove.current?.abandoned return controller } @@ -1229,7 +1323,16 @@ fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean = * visibly settles there. */ fun TimelineDrop.startInstant(zone: TimeZone): Instant = - wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start)) + dropStartInstant(event, date, startMin, clipOffsetMin, zone) + +/** Where [event] starts when its block's top edge lands [startMin] past [date]'s midnight — see [TimelineDrop]. */ +internal fun dropStartInstant( + event: EventInstance, + date: LocalDate, + startMin: Int, + clipOffsetMin: Int, + zone: TimeZone, +): Instant = wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start)) /** * The wall-clock minutes an event lasting [duration] covers when it starts @@ -1301,6 +1404,7 @@ private const val SETTLE_TIMEOUT_MILLIS = 900L */ const val SETTLE_FADE_MILLIS: Int = 250 + /** * The floating block, drawn over the whole calendar so it is free of the day * column's clip and of the scroll viewport's rounded corners. @@ -1308,12 +1412,10 @@ const val SETTLE_FADE_MILLIS: Int = 250 @Composable fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) { var origin by remember { mutableStateOf(Offset.Zero) } - val density = LocalDensity.current - val dark = LocalFloretDarkTheme.current - val use24Hour = LocalUse24HourFormat.current - val locale = currentLocale() val reduceMotion = rememberReduceMotion() - val moveInFlight = moveInFlight() + val drops = dropStates() + val moveInFlight = controller.settledToken in drops.pending + val stripInFlight = controller.stripToken in drops.pending // Both live here rather than beside the controller: they read [drag], which // changes every frame, and this composable is the one that is meant to. @@ -1327,12 +1429,11 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = // recurring drop's scope dialog is up — and then until the grid draws the // drop itself. var handingOver by remember(controller.settling) { mutableStateOf(false) } - val abandoned = abandonedMoves() + val abandoned = controller.settledToken in drops.abandoned LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) { if (controller.settling == null || moveInFlight) return@LaunchedEffect - // Past the mark this drop was let go on, it was abandoned: nothing will - // land and there is nothing to wait for. - if (abandoned == controller.settledAbandonedMark) { + // An abandoned drop: nothing will land and there is nothing to wait for. + if (!abandoned) { delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS) } handingOver = true @@ -1342,9 +1443,10 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = } // The same hold for a bar moved along the all-day strip. - LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) { - if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect - if (abandoned == controller.stripAbandonedMark) { + val stripAbandoned = controller.stripToken in drops.abandoned + LaunchedEffect(controller.stripSettling, stripInFlight, controller.stripSeated, stripAbandoned) { + if (controller.stripSettling == null || stripInFlight) return@LaunchedEffect + if (!stripAbandoned) { delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS) } controller.releaseStrip() @@ -1358,6 +1460,12 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = .clearAndSetSemantics { } .onGloballyPositioned { origin = it.positionInRoot() }, ) { + controller.held.forEach { parked -> + key(parked.token) { HeldTimelineCopy(controller, parked, origin) } + } + controller.heldStrips.forEach { parked -> + key(parked.token) { HeldStripSettle(controller, parked) } + } val drag = controller.drag ?: controller.settling ?: return@Box // Landed: the copy sinks back to the grid's own plane while the write runs. val landed = controller.drag == null @@ -1371,6 +1479,58 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = animationSpec = tween(SETTLE_FADE_MILLIS), label = "drag-handover", ) + TimelineCopy(drag, controller.geometry, origin, lift, copyAlpha) + } +} + +/** + * [TimelineDragController.ghosts] for [block], derived so the block recomposes + * only when its own answer changes rather than on every lift, park and release. + */ +@Composable +fun TimelineDragController.ghostsDerived(block: TimedBlock): Boolean { + val ghosted by remember(this, block) { derivedStateOf { ghosts(block) } } + return ghosted +} + +/** A drop parked by a newer pickup: held at its landing slot until its own write settles. */ +@Composable +private fun HeldTimelineCopy(controller: TimelineDragController, parked: HeldDrop, origin: Offset) { + val alpha = rememberHeldSettle( + token = parked.token, + seated = parked.seated, + timeoutMillis = SETTLE_TIMEOUT_MILLIS, + onHandOver = { controller.handOverHeld(parked.token) }, + onRelease = { controller.releaseHeld(parked.token) }, + ) + TimelineCopy(parked.copy, controller.geometry, origin, lift = 0f, copyAlpha = alpha) +} + +/** A strip drop parked by a newer one: shown at its new days until its own write settles. */ +@Composable +private fun HeldStripSettle(controller: TimelineDragController, parked: HeldStrip) { + rememberHeldSettle( + token = parked.token, + seated = parked.seated, + timeoutMillis = SETTLE_TIMEOUT_MILLIS, + onHandOver = {}, + onRelease = { controller.releaseHeldStrip(parked.token) }, + ) +} + +/** [drag]'s pieces drawn over the calendar, clipped to the timeline's viewport. */ +@Composable +private fun TimelineCopy( + drag: TimelineDrag, + geometry: TimelineGeometry, + origin: Offset, + lift: Float, + copyAlpha: Float, +) { + val density = LocalDensity.current + val dark = LocalFloretDarkTheme.current + val use24Hour = LocalUse24HourFormat.current + val locale = currentLocale() val paint = eventPaint(drag.event, dark) val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } // Read off the event rather than the held block, so grabbing either half @@ -1386,7 +1546,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = // so no column clip or rounded corner cuts it, but it still belongs to // the timeline: a piece a whole day tall reaches far past both ends of // the viewport, and unclipped it paints over the headers above (#267). - val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot() + val port = geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot() // How much of each piece the viewport actually shows. A multi-day event // has a piece per day, and the widest one is a full 24 hours — taller // than the screen, its top at a midnight scrolled out of sight. Picking @@ -1438,7 +1598,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = ) } } - } } /** One piece of a dragged block, floating over the calendar. */ 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 9e7df88e..f1c9d14e 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 @@ -14,8 +14,7 @@ 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 de.jeanlucmakiola.calendula.ui.common.LocalEventMove -import kotlinx.coroutines.flow.StateFlow +import de.jeanlucmakiola.calendula.ui.common.nextMoveToken import kotlinx.datetime.LocalDate import kotlin.math.abs @@ -107,11 +106,22 @@ data class MonthChipDrag( val time: String? = null, ) +/** A drop parked by a newer pickup while its write still runs — see [MonthDragController.held]. */ +data class HeldChip( + val copy: MonthChipDrag, + val token: Long, + val handedOver: Boolean = false, + /** Where the grid now draws the drop, in root coordinates, once a row seats it. */ + val seat: Offset? = null, +) + /** Where a finished chip drag asks its event to go, as a whole-day shift. */ data class MonthChipDrop( val event: EventInstance, val grabDate: LocalDate, val targetDate: LocalDate, + /** The drop's identity, which its held copy waits on — see [MonthDragController.settledToken]. */ + val token: Long, ) /** @@ -167,19 +177,22 @@ class MonthDragController { private set /** - * The last drop this controller made, kept past [release] for [beginUndo] — - * the confirmation chip carries Undo long after the copy is gone. + * This controller's recent drops by token, kept past [release] for + * [beginUndo] — the confirmation chip carries Undo long after the copy is + * gone, and it may be any of them the chip offers to undo. */ - private var undoable: MonthChipDrag? = null + private val undoable = LinkedHashMap() /** - * The abandoned-move counter, read at the drop so the mark can't be a - * composition behind the tick it is compared against. + * Earlier drops still being written when another chip was picked up. They + * keep their copy and their ghost until their own token settles, rather than + * vanishing and flashing back on the old day (#494). */ - var abandonedTicks: StateFlow? = null + var held: List by mutableStateOf(emptyList()) + private set - /** What [abandonedTicks] stood at when the settling chip was let go. */ - var settledAbandonedMark: Int = 0 + /** The token of the drop (or undo) [settling] is waiting on. */ + var settledToken: Long = 0L private set private var event: EventInstance? = null @@ -208,7 +221,7 @@ class MonthDragController { this.event = event this.grabDate = grabDate this.time = time - settling = null + parkSettling() isDragging = true liftedInstanceId = event.instanceId grab = pointerInRoot - chipInRoot @@ -234,6 +247,39 @@ class MonthDragController { settledGhost = false } + /** + * Whether [event]'s chip belongs to a held drop and must stay a ghost: its + * source, or once a row seats it, the chip the grid draws on its target day. + */ + fun isHeldChip(event: EventInstance, days: List?): Boolean = held.any { parked -> + !parked.handedOver && ( + parked.copy.event.instanceId == event.instanceId || + ( + parked.seat != null && days != null && parked.copy.targetDate in days && + isSameEvent(event, parked.copy.event) + ) + ) + } + + /** Move a still-settling drop to [held] before a new pickup takes the slot. */ + private fun parkSettling() { + val landed = settling + if (landed != null) held = held + HeldChip(landed, settledToken) + settling = null + settledInRoot = null + settledGhost = false + } + + /** Stop ghosting the held drop [token]'s source while its copy fades. */ + fun handOverHeld(token: Long) { + held = held.map { if (it.token == token) it.copy(handedOver = true) else it } + } + + /** Drop the held copy [token] once it has faded. */ + fun releaseHeld(token: Long) { + held = held.filterNot { it.token == token } + } + /** * 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. @@ -250,6 +296,13 @@ class MonthDragController { * nearest where the chip was let go is the one the finger was over. */ fun noteSettled(token: Any) { + val row = rows[token] + if (row != null && held.any { it.seat == null }) { + held = held.map { parked -> + val target = parked.copy.targetDate + if (parked.seat != null || target == null) parked else parked.copy(seat = row.seatOf(parked.copy.event, target)) + } + } val landed = settling ?: return val target = landed.targetDate ?: return val at = rows[token]?.seatOf(landed.event, target) ?: return @@ -265,16 +318,16 @@ class MonthDragController { * there is no drop of this controller's left to undo, or the grid doesn't * seat the moved event where the chip would have to start from. */ - fun beginUndo(): Boolean { - val last = undoable ?: return false - undoable = null + fun beginUndo(token: Long, dropToken: Long): Boolean { + val last = undoable.remove(dropToken) ?: return false + parkSettling() 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 - settledAbandonedMark = abandonedTicks?.value ?: 0 + settledToken = token return true } @@ -294,10 +347,11 @@ class MonthDragController { ) liftedInstanceId = landed.event.instanceId settledGhost = true - undoable = settling - settledAbandonedMark = abandonedTicks?.value ?: 0 + settledToken = nextMoveToken() + undoable[settledToken] = requireNotNull(settling) + if (undoable.size > UNDOABLE_KEPT) undoable.remove(undoable.keys.first()) drops++ - return MonthChipDrop(landed.event, landed.grabDate, target) + return MonthChipDrop(landed.event, landed.grabDate, target, settledToken) } /** @@ -355,9 +409,10 @@ class MonthDragController { @Composable fun rememberMonthDragController(): MonthDragController { - val controller = remember { MonthDragController() } - controller.abandonedTicks = LocalEventMove.current?.abandoned - return controller + return remember { MonthDragController() } } val LocalMonthDrag = compositionLocalOf { null } + +/** How many recent drops a month controller keeps for an undo to carry back. */ +private const val UNDOABLE_KEPT = 8 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 83001dfb..46b8a0f1 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 @@ -125,11 +125,11 @@ import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow 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.abandonedMoves +import de.jeanlucmakiola.calendula.ui.common.dropStates 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.rememberHeldSettle 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 @@ -515,7 +515,7 @@ fun MonthScreen( val chipDrag = rememberMonthDragController() // Undo moves the event back, so the chip travels back too. The // signal comes from the write, not from the chip that offers it. - OnUndoStarted { chipDrag.beginUndo() } + OnUndoStarted { start -> chipDrag.beginUndo(start.token, start.dropToken) } Box( modifier = Modifier .padding(innerPadding) @@ -591,11 +591,11 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L @Composable private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Boolean) { var origin by remember { mutableStateOf(Offset.Zero) } - val dark = LocalFloretDarkTheme.current - val density = LocalDensity.current val timeFit = rememberMonthChipTimeFit(showStartTimes) val reduceMotion = rememberReduceMotion() - val moveInFlight = moveInFlight() + val drops = dropStates() + val moveInFlight = controller.settledToken in drops.pending + val abandoned = controller.settledToken in drops.abandoned // Here rather than beside the controller: this reads [drag], which changes // every frame, and this composable is the one that is meant to. DragSnapHaptics(controller.drag?.targetDate, controller.drops) @@ -608,14 +608,12 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) } val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec() val seat = controller.settledInRoot - val abandoned = abandonedMoves() LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) { val landed = controller.settling if (landed == null || moveInFlight) return@LaunchedEffect if (seat == null) { - // Past the mark this drop was let go on, it was abandoned and no - // seat is ever coming. - if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS) + // An abandoned drop has no seat coming. + if (!abandoned) delay(MONTH_SETTLE_TIMEOUT_MILLIS) } else { if (!gliding) glide.snapTo(landed.topLeftInRoot) gliding = true @@ -635,6 +633,9 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo .clearAndSetSemantics { } .onGloballyPositioned { origin = it.positionInRoot() }, ) { + controller.held.forEach { parked -> + key(parked.token) { HeldMonthChip(controller, parked, origin, timeFit) } + } val drag = controller.drag ?: controller.settling ?: return@Box // Landed: the copy sinks back to the grid's own plane while the write runs. val lift by animateFloatAsState( @@ -646,42 +647,93 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo animationSpec = tween(SETTLE_FADE_MILLIS), label = "chip-handover", ) - MonthBar( - event = drag.event, - dark = dark, - continuesLeft = false, - continuesRight = false, - // The time the source chip carried, drawn only if this one-column - // cut-out of it has the room — which a slice of a multi-day bar has - // not, however wide the bar was. - time = drag.time, + MonthChipCopy( + drag = drag, + // Read at layout, so the glide doesn't recompose every frame. + at = { if (gliding) glide.value else drag.topLeftInRoot }, + origin = origin, + lift = lift, + alpha = copyAlpha, timeFit = timeFit, - chipWidth = with(density) { drag.sizePx.width.toDp() }, - modifier = Modifier - // 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( - (at.x - origin.x).roundToInt(), - (at.y - origin.y).roundToInt(), - ) - } - .width(with(density) { drag.sizePx.width.toDp() }) - .height(with(density) { drag.sizePx.height.toDp() }) - .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp) - .graphicsLayer { - scaleX = 1f + 0.04f * lift - scaleY = 1f + 0.04f * lift - shadowElevation = 8.dp.toPx() * lift - alpha = copyAlpha - shape = EventChipShape - clip = false - }, ) } } +/** A drop parked by a newer pickup: held on its landing day until its own write settles. */ +@Composable +private fun HeldMonthChip( + controller: MonthDragController, + parked: HeldChip, + origin: Offset, + timeFit: MonthChipTimeFit?, +) { + val reduceMotion = rememberReduceMotion() + val glide = remember { Animatable(parked.copy.topLeftInRoot, Offset.VectorConverter) } + val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec() + val seat = parked.seat + val landed = parked.token !in dropStates().pending + // Carried onto its seat once the write is in, as the settling chip is. + LaunchedEffect(seat, landed) { + if (seat == null || !landed) return@LaunchedEffect + if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec) + } + val alpha = rememberHeldSettle( + token = parked.token, + seated = seat != null, + timeoutMillis = MONTH_SETTLE_TIMEOUT_MILLIS, + onHandOver = { controller.handOverHeld(parked.token) }, + onRelease = { controller.releaseHeld(parked.token) }, + ) + MonthChipCopy(parked.copy, { glide.value }, origin, lift = 0f, alpha = alpha, timeFit = timeFit) +} + +/** [drag]'s chip floating over the grid with its top left at [at], in root coordinates. */ +@Composable +private fun MonthChipCopy( + drag: MonthChipDrag, + at: () -> Offset, + origin: Offset, + lift: Float, + alpha: Float, + timeFit: MonthChipTimeFit?, +) { + val density = LocalDensity.current + val dark = LocalFloretDarkTheme.current + MonthBar( + event = drag.event, + dark = dark, + continuesLeft = false, + continuesRight = false, + // The time the source chip carried, drawn only if this one-column + // cut-out of it has the room — which a slice of a multi-day bar has + // not, however wide the bar was. + time = drag.time, + timeFit = timeFit, + chipWidth = with(density) { drag.sizePx.width.toDp() }, + modifier = Modifier + // Absolute: these are root coordinates, and the direction-aware + // offset would mirror them across the screen in an RTL layout. + .absoluteOffset { + val topLeft = at() + IntOffset( + (topLeft.x - origin.x).roundToInt(), + (topLeft.y - origin.y).roundToInt(), + ) + } + .width(with(density) { drag.sizePx.width.toDp() }) + .height(with(density) { drag.sizePx.height.toDp() }) + .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp) + .graphicsLayer { + scaleX = 1f + 0.04f * lift + scaleY = 1f + 0.04f * lift + shadowElevation = 8.dp.toPx() * lift + this.alpha = alpha + shape = EventChipShape + clip = false + }, + ) +} + /** * Content for both scrolling styles. No swipe detector and no [AnimatedContent]: * vertical scrolling owns the gesture, and the list never swaps wholesale — it @@ -2258,7 +2310,7 @@ private fun MonthWeekRow( // 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, // which is what changes when the re-read lands. - LaunchedEffect(week, dragController?.settling) { + LaunchedEffect(week, dragController?.settling, dragController?.held) { dragController?.noteSettled(rowToken) } @@ -2691,6 +2743,8 @@ private fun monthChipDragModifier( beginMillis = drop.event.start.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(), target = MoveTarget.ByDays(delta), + isAllDay = drop.event.isAllDay, + token = drop.token, ), ) } @@ -2836,8 +2890,17 @@ private fun MonthBar( val moveAction = eventMoveAction(event) // The source stays put as a ghost while its floating copy travels. val monthDrag = LocalMonthDrag.current - val lifted = monthDrag?.liftedInstanceId == event.instanceId || - monthDrag?.isSettledChip(event, days) == true + // Derived, so a chip recomposes only when its own answer changes rather than + // on every lift, park and release anywhere in the grid. + val lifted by remember(monthDrag, event, days) { + derivedStateOf { + monthDrag != null && ( + monthDrag.liftedInstanceId == event.instanceId || + monthDrag.isSettledChip(event, days) || + monthDrag.isHeldChip(event, days) + ) + } + } val ghost = ghostAlpha(lifted) val shape = monthBarShape(continuesLeft, continuesRight) Box( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 9fde6833..cb6607fa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -157,6 +157,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.TimelineDragController +import de.jeanlucmakiola.calendula.ui.common.ghostsDerived import de.jeanlucmakiola.calendula.ui.common.StripDrag import de.jeanlucmakiola.calendula.ui.common.StripDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay @@ -584,7 +585,9 @@ private fun WeekContent( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(), + isAllDay = drop.event.isAllDay, target = MoveTarget.Start(start), + token = drop.token, ), ) } @@ -603,7 +606,9 @@ private fun WeekContent( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(), + isAllDay = drop.event.isAllDay, target = MoveTarget.Span(drop.start, drop.end), + token = drop.token, ), ) } @@ -615,10 +620,12 @@ private fun WeekContent( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(), + isAllDay = drop.event.isAllDay, target = when (drop) { is StripDrop.Days -> MoveTarget.ByDays(drop.days) is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end) }, + token = drop.token, ), ) } @@ -760,8 +767,7 @@ private fun WeekPager( // Until its days arrive a page shows their frame without events. val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) } // A bar held over other days in the strip makes room there before it is dropped. - val held = (dragController.stripDrag ?: dragController.stripSettling) - ?.takeIf { page == pagerState.currentPage } + val held = dragController.stripPreviews().takeIf { page == pagerState.currentPage }.orEmpty() val zone = LocalTimelineZone.current val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) } val stripHeight = shown?.allDayStripHeight() @@ -786,17 +792,24 @@ private fun WeekPager( } /** - * [this] with the strip bar [held] sideways laid out at the days it is over, so - * the bars there move aside live. The bar keeps its own event, and with it its - * key and the gesture on it; one moved off the page keeps a lane it is not seen in. + * [this] with the strip bars [held] sideways laid out at the days they are over, + * so the bars there move aside live. Each bar keeps its own event, and with it + * its key and the gesture on it; one moved off the page keeps a lane it is not + * seen in. */ -internal fun WeekUiState.Success.withStripPreview(held: StripDrag?, zone: TimeZone): WeekUiState.Success { - if (held == null || held.overGrid || held.dayShift == 0) return this - val id = held.event.instanceId - val original = allDaySpans.firstOrNull { it.event.instanceId == id } ?: return this - val events = allDaySpans.map { if (it.event.instanceId == id) it.event.shiftedDays(held.dayShift, zone) else it.event } - val spans = layoutAllDay(events, days, zone).map { if (it.event.instanceId == id) it.copy(event = original.event) else it } - return copy(allDaySpans = if (spans.any { it.event.instanceId == id }) spans else spans + original.copy(lane = 0)) +internal fun WeekUiState.Success.withStripPreview(held: List, zone: TimeZone): WeekUiState.Success { + val shifts = held.filter { !it.overGrid && it.dayShift != 0 }.associate { it.event.instanceId to it.dayShift } + if (shifts.isEmpty()) return this + val originals = allDaySpans.filter { it.event.instanceId in shifts }.associateBy { it.event.instanceId } + if (originals.isEmpty()) return this + val events = allDaySpans.map { span -> + shifts[span.event.instanceId]?.let { span.event.shiftedDays(it, zone) } ?: span.event + } + val spans = layoutAllDay(events, days, zone).map { span -> + originals[span.event.instanceId]?.let { span.copy(event = it.event) } ?: span + } + val laidOut = spans.mapTo(HashSet()) { it.event.instanceId } + return copy(allDaySpans = spans + originals.values.filter { it.event.instanceId !in laidOut }.map { it.copy(lane = 0) }) } private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance { @@ -804,6 +817,17 @@ private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates)) } +/** Whether these bars draw the strip drop [drop] at the days it was dropped on. */ +private fun List.drawStripDrop(drop: StripDrag, zone: TimeZone): Boolean { + val target = drop.event.spanFirstDay(zone).plus(drop.dayShift, DateTimeUnit.DAY) + return any { bar -> + bar.event.instanceId != drop.event.instanceId && + (bar.event.eventId == drop.event.eventId || + (drop.event.title.isNotBlank() && bar.event.title == drop.event.title)) && + bar.event.spanFirstDay(zone) == target + } +} + /** A page with no events yet, for one whose days are still loading. */ private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success { val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) } @@ -1186,17 +1210,11 @@ private fun AllDayStrip( val sideways = held?.takeIf { !it.overGrid } val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false } val settling = dragController.stripSettling?.takeIf { active } + val heldStrips = dragController.heldStrips.takeIf { active }.orEmpty() // Seated once the re-read draws the event at the days it was dropped on. - LaunchedEffect(settling, bars) { - if (settling == null) return@LaunchedEffect - val target = settling.event.spanFirstDay(zone).plus(settling.dayShift, DateTimeUnit.DAY) - val landed = bars.any { bar -> - bar.event.instanceId != settling.event.instanceId && - (bar.event.eventId == settling.event.eventId || - (settling.event.title.isNotBlank() && bar.event.title == settling.event.title)) && - bar.event.spanFirstDay(zone) == target - } - if (landed) dragController.seatStrip() + LaunchedEffect(settling, heldStrips, bars) { + if (settling != null && bars.drawStripDrop(settling, zone)) dragController.seatStrip() + heldStrips.forEach { if (!it.seated && bars.drawStripDrop(it.drag, zone)) dragController.seatHeldStrip(it.token) } } Row( @@ -1581,7 +1599,7 @@ private fun DayColumnCard( widthFraction = 1f / block.laneCount, y = top, height = height, - landing = dragController.ghosts(block), + landing = dragController.ghostsDerived(block), ) val topInset = hourCellBlockInset(showHourGrid, cuts.top) val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) @@ -1749,7 +1767,7 @@ private fun EventBlock( onCancel = dragController::cancel, onRelease = { dragController.select(block.event, fromLift = draggable) }, ) - val lifted = draggable && dragController.ghosts(block) + val lifted = draggable && dragController.ghostsDerived(block) // Released in place, the block takes over the copy's lift without a dip. val takesOverLift = selected && dragController.selection?.fromLift == true val ghostFade = ghostAlpha(lifted) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed0a4437..278cc7ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,6 +110,9 @@ Couldn\'t move the event Calendula needs write access to move events That event no longer exists + That event moved before your drop could be saved + Too many moves at once, so that one wasn\'t saved + Moves made while the question was open weren\'t saved Can\'t move an event past the end of its series A cancelled series can only move as a whole, and this would change which days it falls on diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt index e44e6f33..86215696 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -13,10 +13,12 @@ import de.jeanlucmakiola.calendula.testEvent import de.jeanlucmakiola.calendula.testPrefs import de.jeanlucmakiola.calendula.testRepository import de.jeanlucmakiola.calendula.testSettings +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalDate @@ -79,15 +81,43 @@ class RescheduleViewModelTest { status = status, ) - private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel { + /** + * [present] are the events whose occurrence the provider still reports where + * a drop picked it up; [io] a [StandardTestDispatcher] keeps a drop in flight. + */ + private fun viewModel( + tempDir: Path, + fake: FakeCalendarDataSource, + present: List = listOf(42L, 7L), + io: CoroutineDispatcher = dispatcher, + ): RescheduleViewModel { + fake.occurrencesAt(present) val repo = testRepository( - fake, tempDir, dispatcher, + fake, tempDir, io, prefs = testPrefs(tempDir, CoroutineScope(dispatcher)), settings = testSettings(tempDir, CoroutineScope(dispatcher)), ) - return RescheduleViewModel(repo, dispatcher, FakeZoneSource()) + return RescheduleViewModel(repo, io, FakeZoneSource()) } + private fun slowViewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel = + viewModel(tempDir, fake, io = StandardTestDispatcher(dispatcher.scheduler)) + + /** Every query answers with an occurrence of each of [ids] exactly at the queried span. */ + private fun FakeCalendarDataSource.occurrencesAt(ids: List) { + instancesResult = { begin, end -> + ids.map { id -> + testEvent( + id = id, + start = Instant.fromEpochMilliseconds(begin), + end = Instant.fromEpochMilliseconds(end), + ) + } + } + } + + private val RescheduleViewModel.abandonedCount: Int get() = drops.value.abandoned.size + private fun CoroutineScope.activate(vm: RescheduleViewModel): Job = launch { vm.movableCalendarIds.collect {} } @@ -97,6 +127,8 @@ class RescheduleViewModelTest { beginMillis = beginMillis, endMillis = endMillis, target = MoveTarget.ByDays(2), + isAllDay = false, + token = nextMoveToken(), ) /** A drop an hour later, expressed the way a timeline drag expresses one. */ @@ -105,6 +137,8 @@ class RescheduleViewModelTest { beginMillis = beginMillis, endMillis = endMillis, target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)), + isAllDay = false, + token = nextMoveToken(), ) /** The end dragged an hour later, the start left where it is. */ @@ -116,6 +150,8 @@ class RescheduleViewModelTest { Instant.fromEpochMilliseconds(beginMillis), Instant.fromEpochMilliseconds(endMillis + 3_600_000L), ), + isAllDay = false, + token = nextMoveToken(), ) @Test @@ -259,21 +295,228 @@ class RescheduleViewModelTest { } @Test - fun `a second drop is refused while the first is still in flight`( + fun `a drop of another event waits for the one in flight, then lands`( @TempDir tempDir: Path, ) = runTest(dispatcher) { val fake = FakeCalendarDataSource().apply { - eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") } + eventDetailResult = { detail() } } + val vm = slowViewModel(tempDir, fake) + + val first = oneHourLater() + val second = toWednesday().copy(eventId = 7L, token = nextMoveToken()) + assertThat(vm.move(first)).isTrue() + assertThat(vm.move(second)).isTrue() + assertThat(vm.drops.value.pending).containsExactly(first.token, second.token) + advanceUntilIdle() + + assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L, 7L).inOrder() + assertThat(vm.drops.value).isEqualTo(DropStates()) + assertThat(vm.inFlight.value).isFalse() + } + + @Test + fun `a failed drop lets go of its own copy only, not the one queued behind it`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail() } + } + val vm = slowViewModel(tempDir, fake) + + // Zero distance: refused in prepare. + val nowhere = oneHourLater().copy( + target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)), + token = nextMoveToken(), + ) + val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken()) + vm.move(nowhere) + vm.move(queued) + advanceUntilIdle() + + assertThat(vm.drops.value.abandoned).containsExactly(nowhere.token) + assertThat(fake.updatedEvents.map { it.first }).containsExactly(7L) + } + + @Test + fun `the same event dropped twice ends up where it was dropped last`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val hour = 3_600_000L + val fake = FakeCalendarDataSource() + val vm = slowViewModel(tempDir, fake) + // Once the first drop is written, the event sits an hour later. + fake.eventDetailResult = { + if (fake.updatedEvents.isEmpty()) detail() else detail(anchorMillis = beginMillis + hour) + } + fake.instancesResult = { _, _ -> + val shift = if (fake.updatedEvents.isEmpty()) 0L else hour + listOf( + testEvent( + id = 42L, + start = Instant.fromEpochMilliseconds(beginMillis + shift), + end = Instant.fromEpochMilliseconds(endMillis + shift), + ), + ) + } + + vm.move(oneHourLater()) + // Picked up from where the grid still drew it, and dropped two days on. + vm.move(toWednesday()) + advanceUntilIdle() + + assertThat(fake.updatedEvents).hasSize(2) + val last = fake.updatedEvents.last().third + assertThat(last.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(13, 0))) + } + + @Test + fun `a drop on a series written moments ago is not written`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + // A whole-series write shifts every occurrence; the grid still draws the + // old ones, and another occurrence may now sit at the dragged time. + val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=DAILY") } } val vm = viewModel(tempDir, fake) + vm.move(oneHourLater()) + advanceUntilIdle() + vm.moveWithScope(RecurringWriteScope.AllEvents) + advanceUntilIdle() + vm.consumeOutcome() - assertThat(vm.move(toWednesday())).isTrue() + val again = toWednesday() + vm.move(again) advanceUntilIdle() - // Parked on the scope dialog, so the first drop still owns the pipeline. - assertThat(vm.move(oneHourLater())).isFalse() + assertThat(vm.scopePrompt.value).isNull() + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Stale) + assertThat(vm.drops.value.abandoned).containsExactly(again.token) + assertThat(fake.updatedEvents).hasSize(1) + } + + @Test + fun `a single occurrence written leaves the rest of its series movable`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } } + val vm = viewModel(tempDir, fake) + vm.move(oneHourLater()) advanceUntilIdle() - assertThat(fake.updatedEvents).isEmpty() + vm.moveWithScope(RecurringWriteScope.ThisEvent) + advanceUntilIdle() + + // Another occurrence, a week on. + val week = 7 * 86_400_000L + vm.move(oneHourLater().copy(beginMillis = beginMillis + week, endMillis = endMillis + week, token = nextMoveToken())) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isNotNull() + } + + @Test + fun `chip messages wait their turn instead of replacing each other`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { id -> + if (id == 42L) detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE", status = EventStatus.Cancelled) else detail() + } + } + val vm = slowViewModel(tempDir, fake) + + vm.move(toWednesday()) + vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken())) + advanceUntilIdle() + + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries) + vm.consumeOutcome() + assertThat((vm.outcome.value as MoveOutcome.Moved).undo?.eventId).isEqualTo(7L) + vm.consumeOutcome() + assertThat(vm.outcome.value).isNull() + } + + @Test + fun `a full queue says so`(@TempDir tempDir: Path) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } } + val vm = slowViewModel(tempDir, fake) + + repeat(9) { i -> vm.move(toWednesday().copy(eventId = 100L + i, token = nextMoveToken())) } + val refused = toWednesday().copy(eventId = 200L, token = nextMoveToken()) + assertThat(vm.move(refused)).isFalse() + + assertThat(vm.drops.value.abandoned).containsExactly(refused.token) + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.QueueFull) + } + + @Test + fun `a deleted event reports itself as gone, not moved`(@TempDir tempDir: Path) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { null } } + val vm = viewModel(tempDir, fake, present = emptyList()) + + vm.move(oneHourLater()) + advanceUntilIdle() + + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone) + } + + @Test + fun `drops waiting when a scope dialog opens are cancelled, not held behind it`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { id -> if (id == 42L) detail(rrule = "FREQ=WEEKLY") else detail() } + } + val vm = slowViewModel(tempDir, fake) + + vm.move(oneHourLater()) + val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken()) + vm.move(queued) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isNotNull() + assertThat(vm.drops.value.abandoned).containsExactly(queued.token) + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Skipped) + + // One made while the dialog is up is refused the same way. + val during = toWednesday().copy(eventId = 7L, token = nextMoveToken()) + assertThat(vm.move(during)).isFalse() + + vm.moveWithScope(RecurringWriteScope.AllEvents) + advanceUntilIdle() + assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L) + // The skip has its turn, then the move it waited on, with its Undo. + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Skipped) + vm.consumeOutcome() + assertThat((vm.outcome.value as MoveOutcome.Moved).undo?.eventId).isEqualTo(42L) + } + + @Test + fun `the first move of a run keeps the chip and its undo`(@TempDir tempDir: Path) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } } + val vm = slowViewModel(tempDir, fake) + + vm.move(oneHourLater()) + vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken())) + advanceUntilIdle() + + val shown = vm.outcome.value as MoveOutcome.Moved + assertThat(fake.updatedEvents).hasSize(2) + assertThat(shown.undo?.eventId).isEqualTo(42L) + } + + @Test + fun `a later move of the same event takes the first move's undo away`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } } + val vm = slowViewModel(tempDir, fake) + + vm.move(oneHourLater()) + vm.move(toWednesday()) + advanceUntilIdle() + + assertThat(fake.updatedEvents).hasSize(2) + assertThat((vm.outcome.value as MoveOutcome.Moved).undo).isNull() } @Test @@ -284,19 +527,19 @@ class RescheduleViewModelTest { advanceUntilIdle() val undo = (vm.outcome.value as MoveOutcome.Moved).undo!! - // A second drop parks on its scope dialog, which holds the pipeline. + // A second drop, of another event, parks on its scope dialog, which holds the pipeline. fake.eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") } - vm.move(toWednesday()) + vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken())) advanceUntilIdle() - val abandoned = vm.abandoned.value + val abandoned = vm.abandonedCount assertThat(vm.undo(undo)).isFalse() advanceUntilIdle() assertThat(fake.updatedEvents).hasSize(1) // Nothing ticks: the refusal started no journey of its own, and the // counter is global — a tick here would release the copy the drop that // *is* in flight is holding, before the grid has drawn it. - assertThat(vm.abandoned.value).isEqualTo(abandoned) + assertThat(vm.abandonedCount).isEqualTo(abandoned) } @Test @@ -311,13 +554,13 @@ class RescheduleViewModelTest { advanceUntilIdle() val undo = (vm.outcome.value as MoveOutcome.Moved).undo!! - val abandoned = vm.abandoned.value + val abandoned = vm.abandonedCount fake.writeError = SecurityException("permission revoked") assertThat(vm.undo(undo)).isTrue() advanceUntilIdle() assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied) - assertThat(vm.abandoned.value).isEqualTo(abandoned + 1) + assertThat(vm.abandonedCount).isEqualTo(abandoned + 1) } @Test @@ -330,12 +573,12 @@ class RescheduleViewModelTest { advanceUntilIdle() val undo = (vm.outcome.value as MoveOutcome.Moved).undo!! - val abandoned = vm.abandoned.value + val abandoned = vm.abandonedCount vm.undo(undo) advanceUntilIdle() assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone) - assertThat(vm.abandoned.value).isEqualTo(abandoned) + assertThat(vm.abandonedCount).isEqualTo(abandoned) } @Test @@ -415,6 +658,8 @@ class RescheduleViewModelTest { target = MoveTarget.Start( Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L), ), + isAllDay = false, + token = nextMoveToken(), ), ) advanceUntilIdle() @@ -435,6 +680,8 @@ class RescheduleViewModelTest { beginMillis = beginMillis, endMillis = endMillis, target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)), + isAllDay = false, + token = nextMoveToken(), ), ) advanceUntilIdle() @@ -556,17 +803,19 @@ class RescheduleViewModelTest { beginMillis = beginMillis, endMillis = endMillis, target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)), + isAllDay = false, + token = nextMoveToken(), ), )).isTrue() advanceUntilIdle() - assertThat(vm.abandoned.value).isEqualTo(1) + assertThat(vm.abandonedCount).isEqualTo(1) // Refused by the provider, after the write was attempted. fake.writeError = SecurityException("revoked") vm.move(oneHourLater()) advanceUntilIdle() assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied) - assertThat(vm.abandoned.value).isEqualTo(2) + assertThat(vm.abandonedCount).isEqualTo(2) } @Test @@ -580,11 +829,11 @@ class RescheduleViewModelTest { vm.move(oneHourLater()) advanceUntilIdle() - assertThat(vm.abandoned.value).isEqualTo(0) + assertThat(vm.abandonedCount).isEqualTo(0) vm.cancelScope() advanceUntilIdle() - assertThat(vm.abandoned.value).isEqualTo(1) + assertThat(vm.abandonedCount).isEqualTo(1) } @Test @@ -596,7 +845,7 @@ class RescheduleViewModelTest { advanceUntilIdle() assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java) - assertThat(vm.abandoned.value).isEqualTo(0) + assertThat(vm.abandonedCount).isEqualTo(0) } @Test @@ -668,6 +917,8 @@ class RescheduleViewModelTest { beginMillis = beginMillis, endMillis = endMillis, target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)), + isAllDay = false, + token = nextMoveToken(), ), ) advanceUntilIdle() @@ -709,7 +960,7 @@ class RescheduleViewModelTest { assertThat(vm.scopePrompt.value).isNull() assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries) - assertThat(vm.abandoned.value).isEqualTo(1) + assertThat(vm.abandonedCount).isEqualTo(1) assertThat(fake.updatedEvents).isEmpty() assertThat(fake.updatedOccurrences).isEmpty() } @@ -739,6 +990,8 @@ class RescheduleViewModelTest { beginMillis = dayStart, endMillis = dayStart + 86_400_000L, target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)), + isAllDay = true, + token = nextMoveToken(), ), ) advanceUntilIdle() @@ -775,6 +1028,8 @@ class RescheduleViewModelTest { beginMillis = dayStart, endMillis = dayStart + 86_400_000L, target = MoveTarget.Span(eight, eight + 1.hours), + isAllDay = true, + token = nextMoveToken(), ), ) advanceUntilIdle() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt index 83a4c78e..c6b7fba2 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt @@ -53,7 +53,7 @@ class TimelineDropTest { @Test fun `dropping the head asks for exactly where it landed`() { val target = LocalDate(2026, 9, 2) - val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0) + val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0, token = 0L) assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone)) } @@ -63,7 +63,7 @@ class TimelineDropTest { val tail = blockOn(sun) val offset = tail.clipOffsetMinutes(sun, zone) // Dragged three days back, same height in the column: Sun 6 -> Thu 3. - val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset) + val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset, token = 0L) // The event began Sat 5 Sep 20:00, so it now begins Wed 2 Sep 20:00 — // the whole event moved three days, and no time of day was invented. @@ -75,7 +75,7 @@ class TimelineDropTest { fun `dragging the tail down the column re-times the event across midnight`() { val offset = blockOn(sun).clipOffsetMinutes(sun, zone) // Tail top pulled from 00:00 to 03:00 on its own day. - val drop = TimelineDrop(overnight, sun, 3 * 60, offset) + val drop = TimelineDrop(overnight, sun, 3 * 60, offset, token = 0L) assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone)) } @@ -84,7 +84,7 @@ class TimelineDropTest { fun `pulling the tail above its own midnight moves the event earlier`() { val offset = blockOn(sun).clipOffsetMinutes(sun, zone) // The tail's top edge dragged an hour above the midnight it rests on. - val drop = TimelineDrop(overnight, sun, -60, offset) + val drop = TimelineDrop(overnight, sun, -60, offset, token = 0L) // Event was Sat 20:00 to Sun 08:00; it now begins an hour earlier. assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(19, 0).toInstant(zone)) @@ -92,7 +92,7 @@ class TimelineDropTest { @Test fun `a top edge dragged past the next midnight resolves onto the day after`() { - val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0) + val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0, token = 0L) assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone)) } @@ -130,7 +130,7 @@ class TimelineDropTest { val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single() val offset = tail.clipOffsetMinutes(sun, berlin) // Dropped back onto its own slot a week earlier, clear of the boundary. - val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset) + val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset, token = 0L) assertThat(drop.startInstant(berlin)) .isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin)) @@ -155,7 +155,7 @@ class TimelineDropTest { ) val block = layoutDay(listOf(event), fallBack, brussels).single() assertThat(block.clipOffsetMinutes(fallBack, brussels)).isEqualTo(0) - val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0) + val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0, token = 0L) assertThat(drop.startInstant(brussels)).isEqualTo(event.start) } @@ -168,7 +168,7 @@ class TimelineDropTest { val tail = layoutDay(listOf(event), fallBack, brussels).single() val offset = tail.clipOffsetMinutes(fallBack, brussels) assertThat(offset).isEqualTo(2 * 60) - val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset) + val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset, token = 0L) assertThat(drop.startInstant(brussels)).isEqualTo(event.start) } @@ -189,7 +189,7 @@ class TimelineDropTest { end = Instant.parse("2026-10-25T02:30:00Z"), ) val block = layoutDay(listOf(event), fallBack, brussels).single() - val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0) + val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0, token = 0L) assertThat(drop.startInstant(brussels)).isEqualTo(Instant.parse("2026-10-25T01:45:00Z")) } @@ -234,7 +234,7 @@ class TimelineDropTest { start = Instant.parse("2026-10-25T01:30:00Z"), end = Instant.parse("2026-10-25T03:00:00Z"), ) - val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels) + val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels) assertThat(drop.start).isEqualTo(event.start) assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T01:45:00Z")) } @@ -246,7 +246,7 @@ class TimelineDropTest { start = Instant.parse("2026-10-25T00:30:00Z"), end = Instant.parse("2026-10-25T01:15:00Z"), ) - val drop = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels) + val drop = resizedSpan(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels) assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z")) assertThat(drop.end).isEqualTo(event.end) } @@ -258,7 +258,7 @@ class TimelineDropTest { start = Instant.parse("2026-10-25T00:20:00Z"), end = Instant.parse("2026-10-25T01:10:00Z"), ) - val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels) + val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels) assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:50:00Z")) } @@ -270,7 +270,7 @@ class TimelineDropTest { start = Instant.parse("2026-10-24T23:00:00Z"), end = Instant.parse("2026-10-25T03:00:00Z"), ) - val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels) + val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels) assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:15:00Z")) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt index 7b61872f..96f78bdd 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -356,12 +356,12 @@ class WeekLayoutTest { ) assertThat(state.allDaySpans.map { it.lane }).containsExactly(0, 0) - val preview = state.withStripPreview(StripDrag(held, dayShift = 2), zone).allDaySpans + val preview = state.withStripPreview(listOf(StripDrag(held, dayShift = 2)), zone).allDaySpans val moved = preview.single { it.event.instanceId == held.instanceId } assertThat(moved.event).isEqualTo(held) assertThat(moved.startCol).isEqualTo(2) assertThat(preview.map { it.lane }).containsExactly(0, 1) - assertThat(state.withStripPreview(StripDrag(held, dayShift = 2, overGrid = true), zone)).isSameInstanceAs(state) + assertThat(state.withStripPreview(listOf(StripDrag(held, dayShift = 2, overGrid = true)), zone)).isSameInstanceAs(state) } }