Third review round: undo by drop, queued chip messages, recent-write stale check, held strip and seat (#494)
This commit is contained in:
1 parent
1925e1c2f4
commit
8d640b595e
11 files changed
+431
-188
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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<DropStates>,
|
||||
/** The token of the undo write that just began — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<Long?>,
|
||||
/** The undo write that just began — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<UndoStart?>,
|
||||
/** 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<Long?>(null)
|
||||
private val NEVER_UNDONE = MutableStateFlow<UndoStart?>(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
|
||||
|
||||
|
||||
@@ -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<DropStates> = _drops.asStateFlow()
|
||||
|
||||
private val _undoStarted = MutableStateFlow<Long?>(null)
|
||||
private val _undoStarted = MutableStateFlow<UndoStart?>(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<Long?> = _undoStarted.asStateFlow()
|
||||
val undoStarted: StateFlow<UndoStart?> = _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<MoveOutcome>()
|
||||
|
||||
/**
|
||||
* 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<Pair<Long, Long?>, TimeMark>()
|
||||
|
||||
/** Drops that arrived while another was in flight, oldest first (#494). */
|
||||
private val queue = ArrayDeque<MoveRequest>()
|
||||
@@ -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
|
||||
|
||||
@@ -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<Long, Instant>,
|
||||
/** 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<HeldStrip> 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<StripDrag> = 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<TimedBlock>) {
|
||||
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<TimedBlock>): 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(
|
||||
|
||||
@@ -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<Long, MonthChipDrag>()
|
||||
|
||||
/**
|
||||
* 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<LocalDate>?): 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<MonthDragController?> { null }
|
||||
|
||||
/** How many recent drops a month controller keeps for an undo to carry back. */
|
||||
private const val UNDOABLE_KEPT = 8
|
||||
@@ -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<Offset>()
|
||||
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(
|
||||
|
||||
@@ -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<StripDrag>, 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<PlacedBar>.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)
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
<string name="event_move_write_denied">Calendula needs write access to move events</string>
|
||||
<string name="event_move_gone">That event no longer exists</string>
|
||||
<string name="event_move_stale">That event moved before your drop could be saved</string>
|
||||
<string name="event_move_queue_full">Too many moves at once, so that one wasn\'t saved</string>
|
||||
<string name="event_move_skipped">Moves made while the question was open weren\'t saved</string>
|
||||
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
|
||||
<string name="event_move_blocked_cancelled_series">A cancelled series can only move as a whole, and this would change which days it falls on</string>
|
||||
|
||||
+72
-17
@@ -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(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user