From 8d640b595eb84b3426d30d418e6a8dc8d5b94e37 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 13 Nov 2026 13:30:49 +0100 Subject: [PATCH] Third review round: undo by drop, queued chip messages, recent-write stale check, held strip and seat (#494) --- .../calendula/ui/common/EventMoveHost.kt | 1 + .../calendula/ui/common/EventMoveScope.kt | 58 +++++-- .../ui/common/RescheduleViewModel.kt | 143 ++++++++++++------ .../calendula/ui/common/TimelineDrag.kt | 136 ++++++++++++----- .../calendula/ui/month/MonthDrag.kt | 44 ++++-- .../calendula/ui/month/MonthScreen.kt | 54 ++++--- .../calendula/ui/week/WeekScreen.kt | 63 +++++--- app/src/main/res/values/strings.xml | 1 + .../ui/common/RescheduleViewModelTest.kt | 89 ++++++++--- .../calendula/ui/common/TimelineDropTest.kt | 26 ++-- .../calendula/ui/week/WeekLayoutTest.kt | 4 +- 11 files changed, 431 insertions(+), 188 deletions(-) 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 1d026711..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 @@ -92,6 +92,7 @@ fun EventMoveHost( 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 ba46db11..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,6 +1,9 @@ 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 @@ -48,8 +51,8 @@ class EventMoveScope( * every visible block reads it as a composition local. */ val drops: StateFlow, - /** The token of the undo write that just began — see `RescheduleViewModel.undoStarted`. */ - val undoStarted: 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, ) { @@ -85,7 +88,7 @@ data class DropStates( private val NO_DROPS = MutableStateFlow(DropStates()) -private val NEVER_UNDONE = MutableStateFlow(null) +private val NEVER_UNDONE = MutableStateFlow(null) /** * Every drop's progress — empty wherever moving is off. A drag overlay keeps @@ -98,22 +101,57 @@ fun dropStates(): DropStates { } /** - * Runs [onUndo] with the undo's token when an undo write begins, and never for - * one that began before this composable came on screen. + * Runs [onUndo] when an undo write begins, and never for one that began before + * this composable came on screen. */ @Composable -fun OnUndoStarted(onUndo: (token: Long) -> Unit) { +fun OnUndoStarted(onUndo: (UndoStart) -> Unit) { val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE - val token by flow.collectAsStateWithLifecycle() - var seen by remember { mutableStateOf(token) } - LaunchedEffect(token) { - val started = token + 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 37d1d380..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 @@ -41,7 +41,10 @@ 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 @@ -50,6 +53,9 @@ 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. */ @@ -78,6 +84,8 @@ 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, ) @@ -98,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 @@ -124,9 +137,12 @@ sealed interface 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 occurrence is no longer where it was picked up — an earlier - * write moved it — and a recurring one can't be followed there, so it is not + * 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 @@ -182,14 +198,14 @@ class RescheduleViewModel @Inject constructor( */ val drops: StateFlow = _drops.asStateFlow() - private val _undoStarted = MutableStateFlow(null) + private val _undoStarted = MutableStateFlow(null) /** - * The token of 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. Its fate then reads off [drops]. + * 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. Its fate then reads off [drops]. */ - val undoStarted: StateFlow = _undoStarted.asStateFlow() + val undoStarted: StateFlow = _undoStarted.asStateFlow() /** * Set while a drop is being prepared, waits on its scope dialog, or is @@ -201,8 +217,18 @@ class RescheduleViewModel @Inject constructor( _inFlight.value = value } - /** Whether the chip has shown anything since the pipeline was last idle — see [report]. */ - private var reportedThisRun = false + /** 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() @@ -248,6 +274,7 @@ class RescheduleViewModel @Inject constructor( fun move(request: MoveRequest): Boolean { if (queue.size >= MAX_QUEUED) { _drops.update { it.ended(request.token, landed = false) } + report(MoveOutcome.QueueFull) return false } if (_scopePrompt.value != null) { @@ -318,7 +345,7 @@ class RescheduleViewModel @Inject constructor( run(next) } else { busy = false - reportedThisRun = false + movedThisRun = false } } @@ -331,23 +358,41 @@ class RescheduleViewModel @Inject constructor( } /** - * Show [outcome] on the chip. While drops run back to back, the first move's - * chip and its Undo stay up rather than being replaced by the next success, - * and a failure replaces it: the grid shows what moved, but only the chip - * says what didn't. A later write to the shown move's own event takes that - * Undo away, since putting the event back would undo the later move too. + * 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) { - val failure = outcome !is MoveOutcome.Moved && outcome !is MoveOutcome.Undone - if (!failure && reportedThisRun) { - val shown = _outcome.value as? MoveOutcome.Moved - if (shown?.undo != null && shown.undo.eventId == eventId) { - _outcome.value = shown.copy(undo = null) + if (outcome is MoveOutcome.Moved) { + if (movedThisRun) { + dropUndoFor(eventId) + return } + movedThisRun = true + } else if (outcome == (notices.lastOrNull() ?: _outcome.value)) { return } - reportedThisRun = true - _outcome.value = outcome + 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 } /** @@ -363,7 +408,8 @@ class RescheduleViewModel @Inject constructor( val token = nextMoveToken() _drops.update { it.started(token) } busy = true - _undoStarted.value = token + _undoStarted.value = UndoStart(token, undo.dropToken) + markWritten(undo.eventId, null) viewModelScope.launch { _outcome.value = try { repository.updateEvent(undo.eventId, undo.moved, undo.restored) @@ -382,18 +428,15 @@ class RescheduleViewModel @Inject constructor( 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(dropped: MoveRequest): PreparedMove? { val zone = zoneSource.current() - val (detail, request) = try { - val detail = repository.eventDetail(dropped.eventId) - // The drop carries the times the grid drew, which an earlier write - // may have moved on since (#494). - detail to if (occurrenceStillAt(dropped)) dropped else followed(dropped, detail, zone) + val detail = try { + repository.eventDetail(dropped.eventId) } catch (e: CancellationException) { throw e } catch (e: NoSuchEventException) { @@ -403,9 +446,15 @@ class RescheduleViewModel @Inject constructor( report(MoveOutcome.Failed) return null } - if (request == 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 original = detail.toEditForm(request.beginMillis, request.endMillis, zone) @@ -507,35 +556,27 @@ class RescheduleViewModel @Inject constructor( } catch (e: Exception) { MoveOutcome.Failed } + 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 } - /** Whether [request]'s occurrence is still exactly where it was picked up. */ - private suspend fun occurrenceStillAt(request: MoveRequest): Boolean { - val begin = Instant.fromEpochMilliseconds(request.beginMillis) - val end = Instant.fromEpochMilliseconds(request.endMillis) - val range = begin..maxOf(end, Instant.fromEpochMilliseconds(request.beginMillis + 1)) - return repository.eventOccurrences(request.eventId, range).any { - it.eventId == request.eventId && it.start == begin && it.end == end - } - } - /** - * [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. Null for a recurring event, whose moved - * occurrence can't be told apart from the rest of its series. + * 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? { - if (detail.rrule != null && !detail.isException) return null + private fun followed(dropped: MoveRequest, detail: EventDetail, zone: TimeZone): MoveRequest { val now = detail.instance val target = when (val target = dropped.target) { is MoveTarget.ByDays -> { - val dayZone = if (now.isAllDay) TimeZone.UTC else zone - val aimed = Instant.fromEpochMilliseconds(dropped.beginMillis).toLocalDateTime(dayZone).date + // 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(dayZone).date + val from = now.start.toLocalDateTime(if (now.isAllDay) TimeZone.UTC else zone).date MoveTarget.ByDays((aimed.toEpochDays() - from.toEpochDays()).toInt()) } else -> target @@ -544,6 +585,7 @@ class RescheduleViewModel @Inject constructor( beginMillis = now.start.toEpochMilliseconds(), endMillis = now.end.toEpochMilliseconds(), target = target, + isAllDay = now.isAllDay, ) } @@ -611,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 f3bb03e1..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 @@ -305,9 +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, - /** The drop's identity, which its held copy waits on; set by the controller's finish. */ - val token: Long = 0L, + 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). */ @@ -350,7 +350,6 @@ internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): /** Which end of an event a resize handle moves (#141). */ enum class ResizeEdge { Top, Bottom } -/** Where a finished resize asks its event to begin and end. */ /** A drop parked by a newer pickup while its write still runs — see [TimelineDragController.held]. */ data class HeldDrop( val copy: TimelineDrag, @@ -358,18 +357,29 @@ data class HeldDrop( 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, - /** The drop's identity, which its held copy waits on; set by the controller's finishResize. */ - val token: Long = 0L, + /** The drop's identity, which its held copy waits on. */ + val token: Long, ) /** @@ -381,17 +391,16 @@ data class TimelineResizeDrop( * 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), @@ -692,6 +701,10 @@ class TimelineDragController { var stripSeated: Boolean by mutableStateOf(false) private set + /** 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 @@ -781,10 +794,12 @@ class TimelineDragController { cancel() if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) { val token = settle(landed) - val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone) + 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 stripToken = nextMoveToken() @@ -803,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 @@ -849,7 +879,7 @@ class TimelineDragController { val landed = settling val origin = settledOrigin if (landed != null && origin != null && !landed.event.isDraft) { - held = held + HeldDrop(landed, settledToken, landed.event.instanceId, origin) + held = held + HeldDrop(landed, settledToken, landed.event.instanceId, origin, settledEnd, settledOnGrid) } settling = null settledOnGrid = false @@ -874,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) && @@ -985,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) } @@ -1002,8 +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) - return drop.copy(token = settle(landed, end = drop.end)) + 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)) } /** @@ -1286,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 @@ -1358,8 +1404,6 @@ private const val SETTLE_TIMEOUT_MILLIS = 900L */ const val SETTLE_FADE_MILLIS: Int = 250 -/** How long a held copy waits after its write before fading — the grid's re-read, unconfirmed. */ -const val HELD_SETTLE_MILLIS: Long = 300L /** * The floating block, drawn over the whole calendar so it is free of the day @@ -1419,6 +1463,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = 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 @@ -1436,30 +1483,41 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = } } +/** + * [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 drops = dropStates() - val pending = parked.token in drops.pending - val abandoned = parked.token in drops.abandoned - var fading by remember { mutableStateOf(false) } - LaunchedEffect(pending, abandoned) { - if (pending) return@LaunchedEffect - // No seat to confirm this one, so give the grid a beat to re-read. - if (!abandoned) delay(HELD_SETTLE_MILLIS) - fading = true - controller.handOverHeld(parked.token) - delay(SETTLE_FADE_MILLIS.toLong()) - controller.releaseHeld(parked.token) - } - val alpha by animateFloatAsState( - targetValue = if (fading) 0f else 1f, - animationSpec = tween(SETTLE_FADE_MILLIS), - label = "held-handover", + 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( 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 004d595a..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 @@ -111,6 +111,8 @@ 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. */ @@ -175,10 +177,11 @@ 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() /** * Earlier drops still being written when another chip was picked up. They @@ -244,9 +247,19 @@ class MonthDragController { settledGhost = false } - /** Whether [event]'s chip is the source of a held drop, so still a ghost. */ - fun isHeldSource(event: EventInstance): Boolean = - held.any { !it.handedOver && it.copy.event.instanceId == event.instanceId } + /** + * 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() { @@ -283,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 @@ -298,9 +318,9 @@ 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(token: Long): 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) @@ -327,8 +347,9 @@ class MonthDragController { ) liftedInstanceId = landed.event.instanceId settledGhost = true - undoable = settling settledToken = nextMoveToken() + undoable[settledToken] = requireNotNull(settling) + if (undoable.size > UNDOABLE_KEPT) undoable.remove(undoable.keys.first()) drops++ return MonthChipDrop(landed.event, landed.grabDate, target, settledToken) } @@ -392,3 +413,6 @@ fun rememberMonthDragController(): 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 f183c8b0..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 @@ -129,7 +129,7 @@ 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.HELD_SETTLE_MILLIS +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 { token -> chipDrag.beginUndo(token) } + OnUndoStarted { start -> chipDrag.beginUndo(start.token, start.dropToken) } Box( modifier = Modifier .padding(innerPadding) @@ -667,25 +667,24 @@ private fun HeldMonthChip( origin: Offset, timeFit: MonthChipTimeFit?, ) { - val drops = dropStates() - val pending = parked.token in drops.pending - val abandoned = parked.token in drops.abandoned - var fading by remember { mutableStateOf(false) } - LaunchedEffect(pending, abandoned) { - if (pending) return@LaunchedEffect - // No seat to confirm this one, so give the grid a beat to re-read. - if (!abandoned) delay(HELD_SETTLE_MILLIS) - fading = true - controller.handOverHeld(parked.token) - delay(SETTLE_FADE_MILLIS.toLong()) - controller.releaseHeld(parked.token) + 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 by animateFloatAsState( - targetValue = if (fading) 0f else 1f, - animationSpec = tween(SETTLE_FADE_MILLIS), - label = "held-chip-handover", + 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, { parked.copy.topLeftInRoot }, origin, lift = 0f, alpha = alpha, timeFit = timeFit) + 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. */ @@ -2311,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) } @@ -2744,6 +2743,7 @@ private fun monthChipDragModifier( beginMillis = drop.event.start.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(), target = MoveTarget.ByDays(delta), + isAllDay = drop.event.isAllDay, token = drop.token, ), ) @@ -2890,9 +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 || - monthDrag?.isHeldSource(event) == 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 f79e4914..b89a92d8 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 @@ -591,6 +592,7 @@ 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, ), @@ -611,6 +613,7 @@ 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, ), @@ -624,6 +627,7 @@ 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) @@ -770,8 +774,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() @@ -796,17 +799,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 { @@ -814,6 +824,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) } @@ -1196,17 +1217,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( @@ -1591,7 +1606,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) @@ -1759,7 +1774,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 5d3a342d..278cc7ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,7 @@ 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 5085188f..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 @@ -127,6 +127,7 @@ class RescheduleViewModelTest { beginMillis = beginMillis, endMillis = endMillis, target = MoveTarget.ByDays(2), + isAllDay = false, token = nextMoveToken(), ) @@ -136,6 +137,7 @@ class RescheduleViewModelTest { beginMillis = beginMillis, endMillis = endMillis, target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)), + isAllDay = false, token = nextMoveToken(), ) @@ -148,6 +150,7 @@ class RescheduleViewModelTest { Instant.fromEpochMilliseconds(beginMillis), Instant.fromEpochMilliseconds(endMillis + 3_600_000L), ), + isAllDay = false, token = nextMoveToken(), ) @@ -368,35 +371,52 @@ class RescheduleViewModelTest { } @Test - fun `a recurring occurrence that moved since the drop is not written`( + fun `a drop on a series written moments ago is not written`( @TempDir tempDir: Path, ) = runTest(dispatcher) { - val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } } - val vm = viewModel(tempDir, fake, present = emptyList()) + // 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() - val drop = toWednesday() - vm.move(drop) + val again = toWednesday() + vm.move(again) advanceUntilIdle() assertThat(vm.scopePrompt.value).isNull() assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Stale) - assertThat(vm.drops.value.abandoned).containsExactly(drop.token) - assertThat(fake.updatedEvents).isEmpty() + assertThat(vm.drops.value.abandoned).containsExactly(again.token) + assertThat(fake.updatedEvents).hasSize(1) } @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()) - + 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() + vm.moveWithScope(RecurringWriteScope.ThisEvent) + advanceUntilIdle() - assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone) + // 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 `a failure stays on the chip while later drops land`(@TempDir tempDir: Path) = runTest(dispatcher) { + 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() @@ -408,8 +428,35 @@ class RescheduleViewModelTest { vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken())) advanceUntilIdle() - assertThat(fake.updatedEvents.map { it.first }).containsExactly(7L) 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 @@ -437,8 +484,10 @@ class RescheduleViewModelTest { vm.moveWithScope(RecurringWriteScope.AllEvents) advanceUntilIdle() assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L) - // The skip stays on the chip; the grid shows the move itself. + // 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 @@ -478,9 +527,9 @@ 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.abandonedCount @@ -609,6 +658,7 @@ class RescheduleViewModelTest { target = MoveTarget.Start( Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L), ), + isAllDay = false, token = nextMoveToken(), ), ) @@ -630,6 +680,7 @@ class RescheduleViewModelTest { beginMillis = beginMillis, endMillis = endMillis, target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)), + isAllDay = false, token = nextMoveToken(), ), ) @@ -752,6 +803,7 @@ class RescheduleViewModelTest { beginMillis = beginMillis, endMillis = endMillis, target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)), + isAllDay = false, token = nextMoveToken(), ), )).isTrue() @@ -865,6 +917,7 @@ class RescheduleViewModelTest { beginMillis = beginMillis, endMillis = endMillis, target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)), + isAllDay = false, token = nextMoveToken(), ), ) @@ -937,6 +990,7 @@ class RescheduleViewModelTest { beginMillis = dayStart, endMillis = dayStart + 86_400_000L, target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)), + isAllDay = true, token = nextMoveToken(), ), ) @@ -974,6 +1028,7 @@ class RescheduleViewModelTest { beginMillis = dayStart, endMillis = dayStart + 86_400_000L, target = MoveTarget.Span(eight, eight + 1.hours), + isAllDay = true, token = nextMoveToken(), ), ) 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) } }