Trim the drag comments down to what they have to say (#68)

This commit is contained in:
2026-08-02 19:29:58 +02:00
parent 5b4c32aff9
commit 916f64d2df
25 changed files with 215 additions and 405 deletions

View File

@@ -89,10 +89,8 @@ internal object EventDetailProjection {
// Recurring rows carry DURATION instead of DTEND; the detail screen // Recurring rows carry DURATION instead of DTEND; the detail screen
// needs it to render a series opened without a named occurrence. // needs it to render a series opened without a named occurrence.
CalendarContract.Events.DURATION, CalendarContract.Events.DURATION,
// Non-null on a modified-occurrence exception row (it points at the // Non-null on a modified-occurrence exception row; "no RRULE" alone
// series). "No RRULE" alone can't tell an exception from a master — a // can't tell an exception from a master (#68).
// sync adapter that leaves the rule on the exception would otherwise
// get an exception written against an exception (#68).
CalendarContract.Events.ORIGINAL_ID, CalendarContract.Events.ORIGINAL_ID,
) )

View File

@@ -46,10 +46,8 @@ val CalendarSource.isEventTarget: Boolean
/** /**
* Whether this calendar's events may have their times rewritten by a drag (#68). * Whether this calendar's events may have their times rewritten by a drag (#68).
* Deliberately not [isEventTarget]: an event living in a switched-off or * Deliberately not [isEventTarget]: a managed event stays editable (reminders,
* non-syncing calendar isn't rendered anyway, while a *managed* event is * notes) yet must never move, since the next contacts sync would put it back.
* editable (reminders, notes) yet must never move — the next contacts sync would
* put it back.
*/ */
val CalendarSource.allowsEventMove: Boolean val CalendarSource.allowsEventMove: Boolean
get() = canModifyContents && !isManaged get() = canModifyContents && !isManaged

View File

@@ -11,22 +11,17 @@ import kotlin.time.Instant
/** /**
* The zone this form's wall-clock times mean, matching what the data layer * The zone this form's wall-clock times mean, matching what the data layer
* resolves them in at write time: the form's own pinned zone, else [deviceZone]. * resolves them in at write time: the form's own pinned zone, else [deviceZone].
* An unparseable pinned id falls back to the device, like the write path does.
*/ */
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone = fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
/** /**
* The form moved so it starts at [newStart], keeping its length. The wall-clock * The form moved so it starts at [newStart], keeping its **instant** duration —
* values are re-derived in the event's own zone, not the device's, so dragging a * a recurring event's length travels to the provider as `DURATION`, so keeping
* pinned event still means what the event means. * wall clock instead would rewrite the series' length across a DST boundary.
* *
* The **instant** duration is preserved, not the wall-clock span: a recurring * All-day events are date-anchored — use [shiftedByDays]; this returns them
* event's length travels to the provider as `DURATION`, so preserving wall clock * untouched.
* would rewrite a whole series' length whenever a shift crosses a DST boundary.
*
* All-day events carry placeholder times and are date-anchored — move them with
* [shiftedByDays] instead; this returns them untouched.
*/ */
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm { fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this if (isAllDay) return this
@@ -39,14 +34,9 @@ fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
} }
/** /**
* The form moved [days] calendar days, keeping its time of day and its length. * The form moved [days] calendar days, keeping its time of day and its span.
* A multi-day event keeps its span rather than collapsing to a single day. * All-day events are pure date arithmetic; a timed one preserves its **instant**
* * duration, for the same reason [shiftedTo] does.
* An all-day event is pure date arithmetic — both ends move, and the placeholder
* times ride along untouched. A timed one re-derives its end from the preserved
* **instant** duration, for the same reason [shiftedTo] does: the length travels
* to a recurring row as `DURATION`, so a move onto a DST changeover that kept
* wall clock would silently rewrite the whole series' length.
*/ */
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm { fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
if (days == 0) return this if (days == 0) return this

View File

@@ -126,9 +126,8 @@ data class EventDetail(
val eventColorKey: String? = null, val eventColorKey: String? = null,
/** /**
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is * True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
* set), not a master. Such a row stands alone — writing an exception against * set) rather than a master, so a reschedule takes the plain whole-row path
* it would nest one exception inside another — so a reschedule always takes * whatever [rrule] a sync adapter left on it.
* the plain whole-row path, whatever [rrule] a sync adapter left on it.
*/ */
val isException: Boolean = false, val isException: Boolean = false,
) )

View File

@@ -5,29 +5,16 @@ import kotlinx.datetime.LocalDate
/** /**
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart]. * [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
* *
* `Events.RRULE` is written verbatim while DTSTART moves, so a rule that names * `Events.RRULE` is written verbatim while DTSTART moves, so `FREQ=WEEKLY;BYDAY=MO`
* its own day — `FREQ=WEEKLY;BYDAY=MO`, what Google and CalDAV write for nearly * would keep naming Monday after the anchor became a Wednesday and the series
* every weekly series — would keep pointing at Monday after the anchor became a * would not move at all — `BYDAY` is re-derived from [newStart].
* Wednesday, and the series would not move at all. `BYDAY` is therefore
* re-derived from [newStart].
* *
* **Only weekly `BYDAY` is realigned, and only for a whole-day move.** The rule * Only weekly `BYDAY` is realigned, and only for a whole-day move: the rule has
* has to agree with the series *anchor*, which is not the occurrence being * to agree with the series *anchor*, and weekday arithmetic is the only kind
* dragged: the anchor moves by the same wall-clock shift, and only weekday * that survives the same wall-clock shift unchanged. Everything else returns
* arithmetic survives that unchanged, because it is uniform mod 7 and every * null, as do rules one moved occurrence can't resolve (`BYDAY=MO,WE`, `2TH`,
* anchor time-of-day crosses the same number of midnights. Day-of-month does not * `BYSETPOS`). The accepted parts are a subset of [parseSimpleRecurrence], so
* — `BYMONTHDAY=28` on a January anchor dragged from Feb 28 to Mar 1 gives an * anything realignable is also a rule [problems] can check the `UNTIL` of.
* anchor of Jan 29 under a `BYMONTHDAY=1` rule, which is not an instance of its
* own rule and materialises a phantom occurrence on any client that trusts
* DTSTART. Those rules return null instead. The caller must also refuse when the
* shift is not a whole number of days, for the same reason (see
* `RescheduleViewModel`).
*
* Null also covers rules one moved occurrence cannot resolve at all
* (`BYDAY=MO,WE`, an ordinal `2TH`, `BYSETPOS`, …) — dropping the extra days
* would delete occurrences. The accepted parts are deliberately a subset of what
* [parseSimpleRecurrence] understands, so anything realignable is also a rule
* [problems] can check the `UNTIL` of.
*/ */
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? { fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
if (oldStart == newStart) return rrule if (oldStart == newStart) return rrule
@@ -54,8 +41,7 @@ fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate):
else -> return null else -> return null
} }
} }
// BYDAY is only simple on a weekly rule (matching parseSimpleRecurrence); // BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
// "every Monday of the month" is a shape this has not been reasoned about.
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
return prefix + rebuilt.joinToString(";") return prefix + rebuilt.joinToString(";")

View File

@@ -398,9 +398,8 @@ fun CalendarHost(
} }
} }
// Scope prompt + confirmation/undo snackbar for a dropped event. Declared // Scope prompt + confirmation/undo snackbar for a dropped event, declared
// right after the calendar views, so any overlay opened afterwards covers // right after the calendar views so later overlays cover it.
// the snackbar rather than the other way round.
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize()) EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
// Search overlay — below detail/edit in the Box so a tapped result's // Search overlay — below detail/edit in the Box so a tapped result's

View File

@@ -17,9 +17,8 @@ import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** /**
* A timed block's own time label, crossfaded rather than replaced. The block * A timed block's own time label, crossfaded rather than replaced — the block
* keeps its identity across a move and slides to the new slot; the label is the * slides to its new slot, so the label shouldn't change in a single frame.
* one thing on it that would otherwise change in a single frame.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -50,14 +49,9 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp) data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
/** /**
* A timed block's placement, tweened rather than jumped. Every bound a block * A timed block's placement, tweened rather than jumped. Continuity comes from
* has changes for a reason the user just caused — a drop landing at a new time, * the caller keying each block by identity; a block composed for the first time
* an undo putting it back, a neighbour arriving and halving both lanes — and all * starts at its target, so nothing flies in on the first frame.
* of them read better as motion than as a new layout appearing.
*
* Continuity comes from the caller keying each block by identity; a block
* composed for the first time starts *at* its target, so nothing flies in from
* the corner on the first frame.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable

View File

@@ -21,29 +21,15 @@ import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /** How far the finger may wander during the hold — well under touch slop. */
* How far the finger may wander during the hold. Deliberately well under touch
* slop (~18dp): past that an ancestor — the vertical scroll, the page swipe —
* claims the gesture, and a hold that survived to there would have opened a dead
* zone where neither the drag nor the page turn happens.
*/
private val PICKUP_TOLERANCE = 6.dp private val PICKUP_TOLERANCE = 6.dp
/** /**
* Pick an event block up with a long press and drag it, without * Pick an event block up with a long press and drag it, without
* `detectDragGesturesAfterLongPress`. * `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
* * ancestor consumes or the finger leaves the block, which a
* The stock detector cancels the press as soon as an ancestor consumes — and * `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
* during the hold the block itself consumes nothing, so the scroll and the page * is deliberately not consumed, so a scroll starting on top of a block survives.
* swipe are free to claim at their own slop — and it also cancels when the finger
* leaves the block, which a `MIN_EVENT_FRACTION`-tall block loses immediately.
* This one keeps its own timeout and its own (much smaller) tolerance, and never
* cancels on leaving the bounds.
*
* Movement before the timeout is deliberately **not** consumed: consuming it
* would kill the ancestor's gesture outright, so a scroll that happens to start
* on top of a block would die. Fast movement means the user meant to scroll —
* abandon quietly. A second finger means a pinch, which owns the gesture.
*/ */
@Composable @Composable
fun rememberEventDragSource( fun rememberEventDragSource(
@@ -68,9 +54,8 @@ fun rememberEventDragSource(
/** /**
* The same pickup, for a surface that carries many draggable pieces rather than * The same pickup, for a surface that carries many draggable pieces rather than
* being one itself — the month grid, whose chips are covered by a full-bleed tap * being one itself — the month grid, whose chips are covered by a full-bleed tap
* layer and so can never take pointer input of their own. [onPickUp] receives the * layer. [onPickUp] receives the press position local to this node and answers
* press position local to this node and answers whether anything is there; * whether anything is there; false abandons the gesture.
* returning false abandons the gesture as if the hold had never completed.
*/ */
@Composable @Composable
fun rememberDragSurface( fun rememberDragSurface(
@@ -121,16 +106,10 @@ fun rememberDragSurface(
if (!took) return@awaitEachGesture if (!took) return@awaitEachGesture
var dropped = false var dropped = false
try { try {
// Once the block is lifted the gesture is ours, so it is // Driven on the initial pass, which runs parent → child: an
// driven on the initial pass and consumed there. That pass // ancestor that outranks us (the pinch) has already consumed
// runs parent → child, which gets both halves right: an // by the time we look, and descendants — the month grid's
// ancestor that outranks us (the pinch, which claims the // full-bleed tap layer — see our consumption.
// moment a second finger lands anywhere in the timeline) has
// already consumed by the time we look, and everything below
// us — the month grid's full-bleed tap layer, which is a
// descendant and would otherwise open the day on lift — sees
// ours. Consumption persists across passes, so the scroll and
// the page swipe stand down on main as well.
while (true) { while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial) val event = awaitPointerEvent(PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id } ?: break val change = event.changes.firstOrNull { it.id == down.id } ?: break
@@ -146,19 +125,15 @@ fun rememberDragSurface(
?.let { currentMove(it.localToRoot(change.position)) } ?.let { currentMove(it.localToRoot(change.position)) }
} }
} finally { } finally {
// Also reached when the pointer node is disposed mid-drag (the // Also reached when the pointer node is disposed mid-drag
// page swapping out under the finger) — that is a cancel, not a // a cancel, not a drop, and must never write.
// drop, and must never write.
if (dropped) currentDrop() else currentCancel() if (dropped) currentDrop() else currentCancel()
} }
} }
} }
} }
/** /** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
* The app's first haptics: a lift on pickup, then a tick every time the drop
* target snaps to a different slot, so the granularity is felt rather than read.
*/
@Composable @Composable
fun DragSnapHaptics(slot: Any?) { fun DragSnapHaptics(slot: Any?) {
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current

View File

@@ -32,12 +32,7 @@ import java.util.Locale
/** How long the confirmation chip stays up, matching a short snackbar. */ /** How long the confirmation chip stays up, matching a short snackbar. */
private const val CHIP_MILLIS = 4_000L private const val CHIP_MILLIS = 4_000L
/** /** How long an *undone* move stays up — shorter, since it offers nothing to act on. */
* How long an *undone* move stays up. Shorter than everything else the chip
* says: it offers nothing to act on, and it confirms a change the user has just
* asked for and can see on the grid behind it, so the full dwell is only the
* chip outstaying what it had to say.
*/
private const val UNDONE_CHIP_MILLIS = 1_600L private const val UNDONE_CHIP_MILLIS = 1_600L
/** What the chip currently reads, kept past the outcome it was built from. */ /** What the chip currently reads, kept past the outcome it was built from. */
@@ -48,12 +43,9 @@ private val FAB_BAND = 88.dp
/** /**
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the * The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
* recurring-scope prompt, and the confirmation chip carrying Undo. * recurring-scope prompt, and the confirmation chip carrying Undo. None of the
* * calendar screens sets a `snackbarHost`, so this hosts its own confirmation as
* None of the four calendar screens sets a `snackbarHost` on its Scaffold, so * a pill on the FAB's band, leaving the calendar visible behind it.
* this hosts its own confirmation — a pill on the FAB's own band at the bottom
* start rather than a full-width bar, so the calendar it confirms a change to
* stays visible behind it.
*/ */
@Composable @Composable
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) { fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
@@ -86,9 +78,8 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
} }
// Held past the outcome being consumed so the chip has something to draw // Held past the outcome being consumed so the chip has something to draw
// while it springs back out. Updated *in composition* rather than from an // while it springs back out. Updated in composition rather than from an
// effect: an effect lands a frame late, so the chip would open carrying the // effect, which would land a frame late and open the chip on stale text.
// previous message and grow into this one while it was still animating in.
val shown = remember { mutableStateOf(ChipContent("", null)) } val shown = remember { mutableStateOf(ChipContent("", null)) }
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) { if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
shown.value = ChipContent(message, moved?.undo) shown.value = ChipContent(message, moved?.undo)
@@ -102,9 +93,8 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val chipMaxWidth = maxWidth - FAB_BAND val chipMaxWidth = maxWidth - FAB_BAND
// A FAB-height band anchored at the bottom start with the FAB's own // A FAB-height band at the bottom start with the FAB's own margin, so
// margin; centring the chip in it lines it up beside the bottom-end FAB // the chip lines up beside the bottom-end FAB.
// at exactly its height, rather than sitting a touch above it.
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)

View File

@@ -19,13 +19,8 @@ import kotlinx.coroutines.flow.StateFlow
/** /**
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by * Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
* whichever event block is being composed. A composition local rather than six * whichever event block is being composed. Null means moving is off entirely and
* layers of parameters: every calendar surface needs the same three things, and * blocks register no drag gesture at all.
* the blocks that need them sit deep inside private composables.
*
* Null means moving is off entirely (no host provided it) — a block then
* registers no drag gesture at all, so a long press keeps its old meaning of
* nothing happening.
*/ */
@Immutable @Immutable
class EventMoveScope( class EventMoveScope(
@@ -36,10 +31,9 @@ class EventMoveScope(
val movableCalendarIds: Set<Long>, val movableCalendarIds: Set<Long>,
val move: (MoveRequest) -> Unit, val move: (MoveRequest) -> Unit,
/** /**
* True while a dropped event is being written including the time its scope * True while a dropped event is being written, including the time its scope
* dialog is up. A flow rather than a value so this scope stays the same * dialog is up. A flow rather than a value so this scope stays the same
* object across a move: it is a composition local every visible block reads, * object across a move — every visible block reads it as a composition local.
* and replacing it would recompose all of them twice per drop.
*/ */
val inFlight: StateFlow<Boolean>, val inFlight: StateFlow<Boolean>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */ /** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
@@ -68,8 +62,7 @@ fun moveInFlight(): Boolean {
/** /**
* Runs [onUndo] when an undo write begins, and never for one that began before * Runs [onUndo] when an undo write begins, and never for one that began before
* this composable came on screen — a view switched to *after* an undo has * this composable came on screen.
* nothing left to carry back.
*/ */
@Composable @Composable
fun OnUndoStarted(onUndo: () -> Unit) { fun OnUndoStarted(onUndo: () -> Unit) {
@@ -88,10 +81,8 @@ const val GHOST_ALPHA: Float = 0.3f
/** /**
* Opacity for a block whose copy is in flight: ghosted from the lift until the * Opacity for a block whose copy is in flight: ghosted from the lift until the
* copy is handed back, then animated up rather than switched. The ghost keeps * copy is handed back, then animated up rather than switched, so the block
* its place through the write and *travels* to the new slot when the grid * appears to travel to its new slot instead of vanishing and reappearing.
* re-reads it, arriving under the copy as that fades — so what the eye follows
* is one block moving, not one vanishing and another appearing.
*/ */
@Composable @Composable
fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState( fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
@@ -101,8 +92,7 @@ fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
/** /**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't * A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved — the block then carries no * pointer-only. Null when this event can't be moved.
* action and registers no drag either.
*/ */
@Composable @Composable
fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? { fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? {

View File

@@ -56,8 +56,7 @@ fun HourGutter(
) { ) {
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() val locale = currentLocale()
// Derived, not read directly: the drag is rewritten every frame, while its // Derived: the drag is rewritten every frame, its snapped start once a slot.
// snapped start only changes once per slot — which is all the gutter shows.
val dragStartMin by remember(dragController) { val dragStartMin by remember(dragController) {
derivedStateOf { dragController.drag?.startMin } derivedStateOf { dragController.drag?.startMin }
} }
@@ -103,8 +102,7 @@ fun HourGutter(
.height(BADGE_HEIGHT) .height(BADGE_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape) .background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
// The dragged block behind it already carries this time; a // The dragged block already announces this time.
// second copy would only duplicate the announcement.
.clearAndSetSemantics { }, .clearAndSetSemantics { },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View File

@@ -15,17 +15,13 @@ import de.jeanlucmakiola.floret.components.OptionCard
/** /**
* How far a write to a recurring event should reach: this occurrence, it and * How far a write to a recurring event should reach: this occurrence, it and
* everything after (a series split), or the whole series. * everything after (a series split), or the whole series. Shared by the edit
* * screen's save and a drag-and-drop reschedule; one of the two carve-outs from
* One of the two carve-outs from the full-screen picker rule — a two-or-three * the full-screen picker rule.
* option decision taken mid-action reads better as a popup than as a near-empty
* screen. Shared by the edit screen's save and a drag-and-drop reschedule, which
* want the same three options for the same reasons.
* *
* [allowOccurrence] drops "only this event" (an exception row can't carry its own * [allowOccurrence] drops "only this event" (an exception row can't carry its own
* rule, so a changed recurrence rules it out); [allowSeries] drops the two wider * rule); [allowSeries] drops the two wider options, for a rule whose days can't
* options, for a rule whose days can't be recalculated from one moved * be recalculated from one moved occurrence — [reason] then says why.
* occurrence — [reason] then says why.
*/ */
@Composable @Composable
fun RecurringScopeDialog( fun RecurringScopeDialog(

View File

@@ -47,18 +47,15 @@ sealed interface MoveTarget {
/** /**
* A whole-day shift, keeping the time of day — a month-grid or all-day drag. * A whole-day shift, keeping the time of day — a month-grid or all-day drag.
* A delta rather than a target date on purpose: the grid already knows how * A delta rather than a target date, so the screen and this view model can't
* many columns the finger crossed, and re-deriving that from a date would * disagree by a day over which zone the event's first day is resolved in.
* mean the screen and this view model each resolving the event's first day
* in their own zone, which can disagree by a day.
*/ */
data class ByDays(val days: Int) : MoveTarget data class ByDays(val days: Int) : MoveTarget
} }
/** /**
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's* * One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
* own times (`Instances.BEGIN`/`END`), exactly as the detail and edit screens * own times (`Instances.BEGIN`/`END`), as the detail and edit screens pass them.
* pass them, so a recurring series resolves the right occurrence.
*/ */
data class MoveRequest( data class MoveRequest(
val eventId: Long, val eventId: Long,
@@ -69,9 +66,8 @@ data class MoveRequest(
/** /**
* A recurring drop waiting for the user to pick how far it reaches. * A recurring drop waiting for the user to pick how far it reaches.
* [occurrenceOnly] means the rule names days that a single moved occurrence * [occurrenceOnly] means the rule names days a single moved occurrence can't
* can't re-derive (`BYDAY=MO,WE`, `2TH`, …), so the only honest option left is * re-derive (`BYDAY=MO,WE`, `2TH`, …) — see [realignRecurrence].
* this one occurrence — see [realignRecurrence].
*/ */
data class MoveScopePrompt(val occurrenceOnly: Boolean) data class MoveScopePrompt(val occurrenceOnly: Boolean)
@@ -113,11 +109,9 @@ sealed interface MoveOutcome {
* Writes a drag-and-drop reschedule (#68) through the same repository calls the * Writes a drag-and-drop reschedule (#68) through the same repository calls the
* edit screen's save uses, so recurring writes, reminder reconciliation and * edit screen's save uses, so recurring writes, reminder reconciliation and
* attendee preservation behave identically. Hosted at `CalendarHost` so one * attendee preservation behave identically. Hosted at `CalendarHost` so one
* instance serves every calendar view and survives view switches. * instance serves every calendar view and survives view switches. The full
* * prefilled form is carried through the write — a stripped one would wipe the
* The full prefilled form is carried through the write — never a stripped one — * reminders and attendees the occurrence-exception path reconciles.
* because the occurrence-exception path reconciles reminders and attendees onto
* the new row, and a partial form would wipe them.
*/ */
@HiltViewModel @HiltViewModel
class RescheduleViewModel @Inject constructor( class RescheduleViewModel @Inject constructor(
@@ -137,27 +131,23 @@ class RescheduleViewModel @Inject constructor(
/** /**
* True from the moment a drop is accepted until its write settles — the * True from the moment a drop is accepted until its write settles — the
* window the dropped block holds its landing position for, rather than * window the dropped block holds its landing position for.
* snapping back to where it came from until the grid re-reads it.
*/ */
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow() val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
private val _undoStarted = MutableStateFlow(0) private val _undoStarted = MutableStateFlow(0)
/** /**
* Ticks the moment an undo write begins before the provider has anything to * Ticks the moment an undo write begins, before the provider has anything to
* re-read. An undo moves an event exactly as a drop does, so the view that * re-read, so the view that drew the drop can carry its chip back rather than
* drew the drop takes this as its cue to carry the chip back rather than let * let it reappear on the old day.
* it reappear on the old day. A counter rather than the undo itself: what a
* view needs is the *timing*, and it already knows what it moved.
*/ */
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow() val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
/** /**
* Set from the moment a drop is accepted until its write settles. Two drops * Set from the moment a drop is accepted until its write settles. Two drops
* of the same recurring event landing inside that window would each compute * of the same recurring event inside that window would compute their shift
* their shift from the same pre-move occurrence, and the data layer applies * from the same pre-move occurrence, and the shifts would compound.
* both to the re-read anchor — so the shifts would compound.
*/ */
private var busy = false private var busy = false
set(value) { set(value) {
@@ -167,8 +157,7 @@ class RescheduleViewModel @Inject constructor(
/** /**
* The calendars whose events may be dragged. Nothing below the UI guards * The calendars whose events may be dragged. Nothing below the UI guards
* this — the repository and data source attempt any write handed to them — * this, so a block outside this set registers no drag gesture at all.
* so a block outside this set registers no drag gesture at all.
*/ */
val movableCalendarIds: StateFlow<Set<Long>> = repository.calendars() val movableCalendarIds: StateFlow<Set<Long>> = repository.calendars()
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() } .map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
@@ -187,10 +176,7 @@ class RescheduleViewModel @Inject constructor(
val updated: EventForm, val updated: EventForm,
/** True for a series master with a rule — an exception row is not one. */ /** True for a series master with a rule — an exception row is not one. */
val isRecurring: Boolean, val isRecurring: Boolean,
/** /** False when the rule names days this move can't re-derive. */
* False when the rule names days this move can't re-derive, so anything
* wider than the single occurrence would leave rule and anchor disagreeing.
*/
val canRealign: Boolean, val canRealign: Boolean,
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */ /** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
val newAnchorDate: LocalDate, val newAnchorDate: LocalDate,
@@ -219,8 +205,7 @@ class RescheduleViewModel @Inject constructor(
/** Answer the scope dialog. */ /** Answer the scope dialog. */
fun moveWithScope(scope: RecurringWriteScope) { fun moveWithScope(scope: RecurringWriteScope) {
val prepared = pending ?: return val prepared = pending ?: return
// Belt and braces against the dialog ever offering a scope the rule // Guard against the dialog ever offering a scope the rule can't carry.
// can't carry: writing it would leave anchor and rule disagreeing.
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
pending = null pending = null
_scopePrompt.value = null _scopePrompt.value = null
@@ -238,10 +223,9 @@ class RescheduleViewModel @Inject constructor(
} }
/** /**
* Put a completed move back where it came from. The outcome deliberately * Put a completed move back where it came from. The outcome stands until the
* stands until the inverse write reports back: clearing it first would drop * inverse write reports back, so the one chip changes what it says rather
* the confirmation chip and open a second one a moment later, rather than * than closing and reopening.
* letting the one chip change what it says.
*/ */
fun undo(undo: MoveUndo) { fun undo(undo: MoveUndo) {
if (busy) return if (busy) return
@@ -288,13 +272,11 @@ class RescheduleViewModel @Inject constructor(
is MoveTarget.Start -> original.shiftedTo(target.instant, zone) is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone) is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
} }
// A zero-distance drop is not a write. Matches the edit form's own // A zero-distance drop is not a write.
// pristine-form no-op, and keeps a mis-aimed long press harmless.
if (shifted == original) return null if (shifted == original) return null
// The UNTIL check is deferred to the write: how far the move reaches // The UNTIL check is deferred to the write, which knows how far the move
// decides which date has to clear it, and "only this event" writes an // reaches and so which date has to clear it.
// exception row that no UNTIL constrains at all.
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) { if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
_outcome.value = MoveOutcome.Failed _outcome.value = MoveOutcome.Failed
return null return null
@@ -305,10 +287,7 @@ class RescheduleViewModel @Inject constructor(
val movedDay = shifted.start.date != original.start.date val movedDay = shifted.start.date != original.start.date
// The series anchor moves by the same *wall-clock* shift as the dragged // The series anchor moves by the same *wall-clock* shift as the dragged
// occurrence, so only a whole-day shift moves it by a predictable number // occurrence, so only a whole-day shift moves it by a predictable number
// of days — a drag that also changes the time of day would carry some // of days; anything else can cross an extra midnight at the anchor.
// anchor times of day across an extra midnight and leave the rule naming
// the wrong weekday. All-day forms shift as bare dates, so they always
// qualify.
val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time
val realigned = if (isRecurring && movedDay) { val realigned = if (isRecurring && movedDay) {
if (wholeDayShift) { if (wholeDayShift) {
@@ -332,8 +311,7 @@ class RescheduleViewModel @Inject constructor(
isRecurring = isRecurring, isRecurring = isRecurring,
canRealign = !isRecurring || !movedDay || realigned != null, canRealign = !isRecurring || !movedDay || realigned != null,
// Where the series row's own DTSTART lands, given the anchor moves by // Where the series row's own DTSTART lands, given the anchor moves by
// the same shift. Only meaningful under [wholeDayShift], which is the // the same shift — only meaningful under wholeDayShift.
// only case a wider-than-one-occurrence write is offered in.
newAnchorDate = anchorDate(detail, original, zone) newAnchorDate = anchorDate(detail, original, zone)
.plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY), .plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY),
) )
@@ -382,14 +360,10 @@ class RescheduleViewModel @Inject constructor(
/** /**
* Whether this write would leave a rule whose `UNTIL` precedes the first day * Whether this write would leave a rule whose `UNTIL` precedes the first day
* it now applies to — the provider then generates nothing and the event * it now applies to — the provider then generates nothing at all. Which date
* silently disappears from every view. * has to clear `UNTIL` depends on the scope: a whole-series move carries the
* * series *anchor*, a split starts at the moved occurrence, and a single
* Which date has to clear `UNTIL` depends on how far the write reaches: a * occurrence becomes an exception row that no `UNTIL` constrains.
* whole-series move carries the series *anchor*, a split starts a new series
* at the moved occurrence, and a single occurrence becomes an exception row
* that no `UNTIL` constrains. Testing the occurrence in every case would
* refuse the perfectly ordinary drag of a bounded series' last occurrence.
*/ */
private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean { private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false
@@ -415,8 +389,7 @@ class RescheduleViewModel @Inject constructor(
/** /**
* Undo is offered only where the inverse is one symmetric write: a * Undo is offered only where the inverse is one symmetric write: a
* non-recurring event (absolute DTSTART/DTEND) and a whole-series move (the * non-recurring event, and a whole-series move. "This event" leaves an
* −Δ wall-clock shift lands on the re-read anchor). "This event" leaves an
* exception row behind and "this and following" splits the series with an * exception row behind and "this and following" splits the series with an
* UNTIL truncation — neither is undone by shifting back. * UNTIL truncation — neither is undone by shifting back.
*/ */
@@ -433,8 +406,7 @@ class RescheduleViewModel @Inject constructor(
/** /**
* Where the dragged occurrence now begins, in the same anchoring the views * Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: a UTC midnight for an all-day event, the real instant * read dates back in: UTC midnight for an all-day event.
* for a timed one.
*/ */
private fun movedStartMillis(prepared: PreparedMove): Long { private fun movedStartMillis(prepared: PreparedMove): Long {
val form = prepared.updated val form = prepared.updated

View File

@@ -45,8 +45,8 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
/** /**
* The time shown in the timeline gutter while a block is dragged: 24h → * The time shown in the timeline gutter while a block is dragged: 24h →
* "09:15", 12h → "9:15". The meridiem is dropped on purpose — the hour labels * "09:15", 12h → "9:15". The meridiem is dropped — the hour labels around it
* around it already carry it, and the gutter is too narrow to hold it. * already carry it, and the gutter is too narrow.
*/ */
fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String { fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String {
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1) val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)

View File

@@ -79,10 +79,9 @@ data class TimelineDrag(
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int) data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
/** /**
* The timeline's live geometry, republished on every layout. Held in plain * The timeline's live geometry, republished on every layout. Plain fields rather
* fields rather than snapshot state on purpose: it changes on every scroll * than snapshot state: it changes on every scroll frame, and the drag loop reads
* frame, and recomposing the screen that often would cost far more than the * it per frame anyway.
* drag loop's own per-frame read of it.
*/ */
class TimelineGeometry { class TimelineGeometry {
/** The day-columns row — scrolling *content*, so its root position folds in the scroll. */ /** The day-columns row — scrolling *content*, so its root position folds in the scroll. */
@@ -102,18 +101,15 @@ class TimelineGeometry {
/** /**
* Whether the columns are laid out right-to-left. Pointer coordinates are * Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored, but the grid is, so the leftmost column is the *last* day * never mirrored but the grid is, so the mapping has to flip with it.
* in Arabic — the mapping has to flip with it.
*/ */
var isRtl: Boolean = false var isRtl: Boolean = false
} }
/** /**
* Hoisted drag state for one timeline (#68). It lives above the per-page * Hoisted drag state for one timeline (#68). It lives above the per-page
* `AnimatedContent` a page change mid-drag would otherwise strand a ghost * `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
* and the block it renders is drawn in an overlay: a `Card` column ends its * block it renders is drawn in an overlay, clear of the day column's clip.
* modifier chain with a clip, so a block offset toward the neighbouring column
* would simply be cut off in place.
*/ */
@Stable @Stable
class TimelineDragController { class TimelineDragController {
@@ -123,19 +119,16 @@ class TimelineDragController {
private set private set
/** /**
* A dropped block, held at the slot it landed on while the write runs. The * A dropped block, held at the slot it landed on while the write runs — the
* grid behind it still shows the old time until the provider notifies and * grid behind it still shows the old time until the query re-reads.
* the query re-reads, so releasing the copy at drop time would snap the
* event back to where it came from for the length of the write.
*/ */
var settling: TimelineDrag? by mutableStateOf(null) var settling: TimelineDrag? by mutableStateOf(null)
private set private set
/** /**
* Which block is lifted, and whether anything is. Separate snapshot state * Which block is lifted, and whether anything is. Separate snapshot state
* from [drag] on purpose: [drag] changes on every frame, and the blocks that * from [drag], which changes every frame, so blocks that only need "am I the
* only need to know "am I the ghost" must not recompose that often. Stays * ghost" don't recompose that often. Stays set through [settling].
* set through [settling], so the source never reappears under the copy.
*/ */
var liftedInstanceId: Long? by mutableStateOf(null) var liftedInstanceId: Long? by mutableStateOf(null)
private set private set
@@ -143,10 +136,8 @@ class TimelineDragController {
/** /**
* Where the settled drop came from, and which event row it belongs to. The * Where the settled drop came from, and which event row it belongs to. The
* instance id alone can't identify the source block for the length of the * instance id alone can't identify the source block for the length of the
* write: the provider regenerates `Instances` rows, so a re-read that still * write: the provider regenerates `Instances` rows, so a re-read carrying the
* carries the *old* time can arrive under a new instance id — and a source * *old* time can arrive under a new instance id.
* matched by instance id would stop being the ghost and flash back to full
* opacity in the slot the event is about to leave.
*/ */
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null) private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
@@ -156,9 +147,8 @@ class TimelineDragController {
/** /**
* Whether the grid itself now draws the settled drop at its landing slot. * Whether the grid itself now draws the settled drop at its landing slot.
* The copy may only be handed back once this is true: releasing on a timer * The copy may only be handed back once this is true, or the source ghost
* puts the source ghost back at full opacity in its *old* slot for whatever * flashes back to full opacity in its old slot.
* is left of the re-read — a flicker of the event where it no longer is.
*/ */
var settledOnGrid: Boolean by mutableStateOf(false) var settledOnGrid: Boolean by mutableStateOf(false)
private set private set
@@ -168,10 +158,8 @@ class TimelineDragController {
private var pointer = Offset.Zero private var pointer = Offset.Zero
/** /**
* The slot the block already occupied when it was picked up. A long press * The slot the block already occupied when it was picked up — not the same
* that never moves must write nothing — and because the target snaps to the * as its start, since the target snaps to the grid (09:07 lifts to 09:00).
* grid, "nothing moved" is not the same as "the start is unchanged": an
* event at 09:07 resolves to 09:00 the instant it lifts.
*/ */
private var originSlot: Pair<LocalDate, Int>? = null private var originSlot: Pair<LocalDate, Int>? = null
@@ -205,9 +193,8 @@ class TimelineDragController {
/** /**
* Whether [block] is the one whose copy is in flight, and so must stay a * Whether [block] is the one whose copy is in flight, and so must stay a
* ghost. While the finger holds it that is the instance it picked up; once * ghost: the instance the finger picked up, or once dropped whatever now
* dropped it is also whatever now sits in the slot it left, however the * sits in the slot it left.
* provider has renumbered it in the meantime.
*/ */
fun ghosts(block: TimedBlock, date: LocalDate): Boolean { fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
if (liftedInstanceId == null) return false if (liftedInstanceId == null) return false
@@ -221,8 +208,7 @@ class TimelineDragController {
/** /**
* What a day column now holds, so a settled drop can tell when the grid has * What a day column now holds, so a settled drop can tell when the grid has
* caught up with it. Matched on the landing slot plus either the event row * caught up with it. Matched on the landing slot plus either the event row
* or its title: a single-occurrence move writes an exception row with a new * or its title, since a single-occurrence move writes a new `eventId`.
* `eventId`, which nothing else about the drop can predict.
*/ */
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) { fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
val landed = settling ?: return val landed = settling ?: return
@@ -254,9 +240,8 @@ class TimelineDragController {
/** /**
* Stop ghosting the source — the grid draws the drop itself by now — while * Stop ghosting the source — the grid draws the drop itself by now — while
* the copy is still on screen dissolving. Un-ghosting only at [release] * the copy is still dissolving. Un-ghosting at [release] instead would leave
* would leave the block under the fading copy dim, and brighten it once the * the block dim under the fading copy and then brighten it: a visible dip.
* copy was gone: a dip the eye reads as the event flickering.
*/ */
fun handOver() { fun handOver() {
liftedInstanceId = null liftedInstanceId = null
@@ -286,8 +271,7 @@ class TimelineDragController {
val origin = grid.positionInRoot() val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
// Clamp the start into the target day; a tail running past midnight is // Clamp the start into the target day; a tail past midnight is fine.
// fine and stays visible on the next day.
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val span = block.endMin - block.startMin val span = block.endMin - block.startMin
// The column the finger is over on screen, and the day that column shows. // The column the finger is over on screen, and the day that column shows.
@@ -312,8 +296,8 @@ class TimelineDragController {
/** /**
* Hold the scroll for the whole drag and nudge it once per frame while the * Hold the scroll for the whole drag and nudge it once per frame while the
* finger sits near an edge. One `scroll` call, not a loop of * finger sits near an edge. One `scroll` call, since a loop of
* `animateScrollBy`: each of those re-acquires the mutex and cancels the last. * `animateScrollBy` would re-acquire the mutex and cancel the last.
*/ */
suspend fun autoScroll() { suspend fun autoScroll() {
val scroll = geometry.scroll ?: return val scroll = geometry.scroll ?: return
@@ -374,17 +358,15 @@ fun TimelineDrop.startInstant(zone: TimeZone): Instant =
const val SETTLE_GRACE_MILLIS: Long = 60L const val SETTLE_GRACE_MILLIS: Long = 60L
/** /**
* How long to wait for a grid that never confirms the drop — the event landed on * How long to wait for a grid that never confirms the drop — it landed on a day
* a day this timeline doesn't show, or the write failed and nothing changed. The * this timeline doesn't show, or the write failed. The copy has to go either way.
* copy has to go either way.
*/ */
private const val SETTLE_TIMEOUT_MILLIS = 900L private const val SETTLE_TIMEOUT_MILLIS = 900L
/** /**
* The hand-over: the copy dissolves over this while the grid's own block slides * The hand-over: the copy dissolves over this while the grid's own block slides
* in under it, so the two overlap rather than one replacing the other. Kept * in under it. Kept short, because a full-width copy sits over any neighbour it
* short, because a full-width copy sits over any neighbour it now shares a lane * now shares a lane with until it is gone.
* with until it is gone.
*/ */
const val SETTLE_FADE_MILLIS: Int = 250 const val SETTLE_FADE_MILLIS: Int = 250
@@ -409,9 +391,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
LaunchedEffect(controller.isDragging) { LaunchedEffect(controller.isDragging) {
if (controller.isDragging) controller.autoScroll() if (controller.isDragging) controller.autoScroll()
} }
// Hold the landed copy until the write is done — including the whole time a // Hold the landed copy until the write is done — including the time a
// recurring drop's scope dialog is up — and then until the grid draws the // recurring drop's scope dialog is up — and then until the grid draws the
// drop itself, so the copy dissolves onto a block that is already there. // drop itself.
var handingOver by remember(controller.settling) { mutableStateOf(false) } var handingOver by remember(controller.settling) { mutableStateOf(false) }
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) { LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect if (controller.settling == null || moveInFlight) return@LaunchedEffect
@@ -425,14 +407,13 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
// A copy of a block that is still in the tree behind it; announcing // A copy of a block still in the tree behind it; announcing it again
// it again would just duplicate the event for the drag's duration. // would duplicate the event for the drag's duration.
.clearAndSetSemantics { } .clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() }, .onGloballyPositioned { origin = it.positionInRoot() },
) { ) {
val drag = controller.drag ?: controller.settling ?: return@Box val drag = controller.drag ?: controller.settling ?: return@Box
// Landed: the copy sinks back to the grid's own plane while the write // Landed: the copy sinks back to the grid's own plane while the write runs.
// runs, so the release reads as the block settling rather than vanishing.
val landed = controller.drag == null val landed = controller.drag == null
val lift by animateFloatAsState( val lift by animateFloatAsState(
targetValue = if (landed || reduceMotion) 0f else 1f, targetValue = if (landed || reduceMotion) 0f else 1f,

View File

@@ -649,8 +649,7 @@ private fun DayColumnCard(
val minEventHeight = hourHeight * MIN_EVENT_FRACTION val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same* // Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates // composable afterwards and tweens there. The ordinal disambiguates
// the rare column holding two occurrences of one series, which would // a column holding two occurrences of one series.
// otherwise be two blocks under one key.
val ordinals = mutableMapOf<Long, Int>() val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block -> blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
@@ -723,8 +722,8 @@ private fun EventBlock(
val fill = eventFill(block.event.color, dark, soften) val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge is // A block clipped at the top continues from the previous day: its top edge
// midnight, not the event's start, so dragging it would invent a time. // is midnight, not the event's start, so dragging it would invent a time.
val draggable = moveAction != null && block.beginsOn(date, zone) val draggable = moveAction != null && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
@@ -738,8 +737,7 @@ private fun EventBlock(
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
Box( Box(
modifier = modifier modifier = modifier
// The source stays put as a ghost while its floating copy travels, // The source stays put as a ghost while its floating copy travels.
// then fades out as the copy settles on its new slot.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)

View File

@@ -28,8 +28,7 @@ class MonthRowGeometry(
val laneCount: Int, val laneCount: Int,
/** /**
* Whether the columns are laid out right-to-left. Pointer coordinates are * Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored, but the grid is, so the leftmost column is the *last* day * never mirrored but the grid is, so the mapping has to flip with it.
* in Arabic.
*/ */
val isRtl: Boolean, val isRtl: Boolean,
/** What this row currently draws in [lane] of column `col`, or null. */ /** What this row currently draws in [lane] of column `col`, or null. */
@@ -57,10 +56,9 @@ class MonthRowGeometry(
} }
/** /**
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither * Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
* id alone will do: the provider hands re-read instances new instance ids, and a * alone will do: re-read instances get new instance ids, and a single-occurrence
* single-occurrence move writes an exception row with a new event id — the title * move writes a new event id — the title survives both.
* is what survives both.
*/ */
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean = private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
chip.eventId == moved.eventId || chip.title == moved.title chip.eventId == moved.eventId || chip.title == moved.title
@@ -83,14 +81,10 @@ data class MonthChipDrop(
) )
/** /**
* Hoisted drag state for the month grid (#68). * Hoisted drag state for the month grid (#68). Every visible week row registers
* * itself so a chip can be carried across rows, the target resolved against
* Every visible week row registers itself, so a chip can be carried across rows: * whichever registered row the finger is over. Rows are keyed by an identity
* the pointer node that took the press keeps receiving events after the finger * token rather than a date: the continuous style can show the same week twice.
* has left its own bounds, and the target is then resolved against whichever
* registered row the finger is actually over. Rows are keyed by an identity token
* rather than by their date, because the continuous style can show the same week
* twice — once in each adjoining month.
*/ */
@Stable @Stable
class MonthDragController { class MonthDragController {
@@ -100,19 +94,16 @@ class MonthDragController {
private set private set
/** /**
* A dropped chip, held on the day it landed on while the write runs. The grid * A dropped chip, held on the day it landed on while the write runs — the
* behind it still shows the old day until the provider notifies and the query * grid behind it still shows the old day until the query re-reads.
* re-reads, so releasing at drop time would snap the event back for the
* length of the write.
*/ */
var settling: MonthChipDrag? by mutableStateOf(null) var settling: MonthChipDrag? by mutableStateOf(null)
private set private set
/** /**
* Which chip is lifted, and whether anything is. Separate snapshot state from * Which chip is lifted, and whether anything is. Separate snapshot state from
* [drag] on purpose: [drag] changes on every frame, while the chips and rows * [drag], which changes every frame, so the chips and rows that only need
* that only need "am I the ghost" / "must I stop clipping" must not. Stays * "am I the ghost" don't recompose that often. Stays set through [settling].
* set through [settling], so the source never reappears under the copy.
*/ */
var liftedInstanceId: Long? by mutableStateOf(null) var liftedInstanceId: Long? by mutableStateOf(null)
private set private set
@@ -120,20 +111,16 @@ class MonthDragController {
/** /**
* Where the grid itself now draws the drop, in root coordinates — published * Where the grid itself now draws the drop, in root coordinates — published
* by whichever week row ends up holding it. The copy is dropped wherever the * by whichever week row ends up holding it. The copy is dropped wherever the
* finger was, but the grid seats it in a lane; without this the copy waits * finger was while the grid seats it in a lane, so without this it would
* out a flat hold beside the chip the grid has already drawn and then * cross-fade across the gap instead of gliding onto its seat.
* cross-fades across the gap, which reads as the event taking its time to
* arrive. With it the copy glides onto its seat and hands over there.
*/ */
var settledInRoot: Offset? by mutableStateOf(null) var settledInRoot: Offset? by mutableStateOf(null)
private set private set
/** /**
* Whether the drop's own chip is still standing in for the copy. Cleared at * Whether the drop's own chip is still standing in for the copy. Cleared at
* [handOver] rather than at [release], for the same reason [liftedInstanceId] * [handOver] rather than [release], or the chip stays dim under a copy that
* is: held to the end it would keep the chip dim under a copy that has * has already faded and brightens afterwards: a visible dip.
* already faded, and brighten it afterwards — a dip the eye reads as the
* event flickering.
*/ */
private var settledGhost: Boolean by mutableStateOf(false) private var settledGhost: Boolean by mutableStateOf(false)
@@ -143,8 +130,7 @@ class MonthDragController {
/** /**
* The last drop this controller made, kept past [release] for [beginUndo] — * The last drop this controller made, kept past [release] for [beginUndo] —
* the confirmation chip carries Undo for four seconds after the copy is long * the confirmation chip carries Undo long after the copy is gone.
* gone. Not snapshot state: nothing draws from it.
*/ */
private var undoable: MonthChipDrag? = null private var undoable: MonthChipDrag? = null
@@ -199,10 +185,6 @@ class MonthDragController {
/** /**
* Whether [days] holds the chip the grid has drawn for the settled drop — * Whether [days] holds the chip the grid has drawn for the settled drop —
* the seat the copy is on its way to, which must ghost until it gets there. * the seat the copy is on its way to, which must ghost until it gets there.
* Matched on the landing day plus either the event row or its title: a
* single-occurrence move writes an exception row with a new `eventId`, and
* the provider hands re-read instances new ids, so neither alone identifies
* the chip the drop became.
*/ */
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean { fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
val landed = settling?.takeIf { settledGhost } ?: return false val landed = settling?.takeIf { settledGhost } ?: return false
@@ -211,9 +193,8 @@ class MonthDragController {
} }
/** /**
* Ask the row keyed [token] whether it now seats the settled chip, and take * Ask the row keyed [token] whether it now seats the settled chip. Both
* its answer. The continuous style can show the same week twice, once in each * copies of a week the continuous style shows twice will answer; the seat
* adjoining month, so both copies of the landing row will answer: the seat
* nearest where the chip was let go is the one the finger was over. * nearest where the chip was let go is the one the finger was over.
*/ */
fun noteSettled(token: Any) { fun noteSettled(token: Any) {
@@ -227,15 +208,10 @@ class MonthDragController {
} }
/** /**
* Send a copy back for an undo, which moves the event exactly as the drop did * Put a chip on the journey an undo's inverse write is about to make, so it
* and so should read the same way rather than teleporting the chip. Nothing * travels back rather than teleporting. Writes nothing itself. False when
* here writes anything: it puts a chip on the journey the inverse write is * there is no drop of this controller's left to undo, or the grid doesn't
* about to make, and the settle that follows is the drop's own. * seat the moved event where the chip would have to start from.
*
* False when there is nothing to carry — no drop of this controller's to undo
* (it happened in another view, or has already been undone), or the grid does
* not seat the moved event where it would have to start from. The chip then
* simply reappears on the day it came from, as it always did.
*/ */
fun beginUndo(): Boolean { fun beginUndo(): Boolean {
val last = undoable ?: return false val last = undoable ?: return false
@@ -251,8 +227,8 @@ class MonthDragController {
/** /**
* End the drag. A real drop keeps its chip on the target day as [settling] * End the drag. A real drop keeps its chip on the target day as [settling]
* until [release] slid over to that day's column, so it lands where the * until [release], slid over to that day's column so it lands where the grid
* grid is about to draw it rather than wherever the finger happened to be. * is about to draw it.
*/ */
fun finish(): MonthChipDrop? { fun finish(): MonthChipDrop? {
val landed = drag val landed = drag
@@ -270,8 +246,7 @@ class MonthDragController {
/** /**
* Stop ghosting the source while the copy is still dissolving — un-ghosting * Stop ghosting the source while the copy is still dissolving — un-ghosting
* only at [release] leaves the chip under it dim and then brightens it, a * at [release] instead leaves the chip dim and then brightens it.
* dip the eye reads as the event flickering.
*/ */
fun handOver() { fun handOver() {
liftedInstanceId = null liftedInstanceId = null
@@ -312,8 +287,8 @@ class MonthDragController {
drag = MonthChipDrag( drag = MonthChipDrag(
event = event, event = event,
grabDate = grabbed, grabDate = grabbed,
// Off the grid entirely (the gutter, the header): keep the last cell // Off the grid entirely: keep the last cell it was over, so a
// it was over, so a wobble past the edge doesn't drop the target. // wobble past the edge doesn't drop the target.
targetDate = resolved ?: drag?.targetDate, targetDate = resolved ?: drag?.targetDate,
topLeftInRoot = pointer - grab, topLeftInRoot = pointer - grab,
sizePx = sizePx, sizePx = sizePx,

View File

@@ -415,12 +415,10 @@ fun MonthScreen(
}, },
) { innerPadding -> ) { innerPadding ->
// Hoisted above every style's grid: a dragged chip is drawn in an // Hoisted above every style's grid: a dragged chip is drawn in an
// overlay so it is free of the week row's clip and, in the scrolling // overlay, clear of the week row's and the list viewport's clips.
// styles, of the list viewport's.
val chipDrag = rememberMonthDragController() val chipDrag = rememberMonthDragController()
// Undo moves the event back, so the chip travels back too rather than // Undo moves the event back, so the chip travels back too. The
// reappearing on its old day. The signal comes from the write, not // signal comes from the write, not from the chip that offers it.
// from the chip that offers it: that lives above every view.
OnUndoStarted { chipDrag.beginUndo() } OnUndoStarted { chipDrag.beginUndo() }
Box( Box(
modifier = Modifier modifier = Modifier
@@ -477,8 +475,7 @@ fun MonthScreen(
/** /**
* How long to wait for a grid that never seats the drop — it landed in a day's * How long to wait for a grid that never seats the drop — it landed in a day's
* "+N" overflow, or on a day this month doesn't show, or the write failed and * "+N" overflow or on a day this month doesn't show, or the write failed.
* nothing changed. The copy has to go either way.
*/ */
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
@@ -493,11 +490,10 @@ private fun MonthDragOverlay(controller: MonthDragController) {
// Here rather than beside the controller: this reads [drag], which changes // Here rather than beside the controller: this reads [drag], which changes
// every frame, and this composable is the one that is meant to. // every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.targetDate) DragSnapHaptics(controller.drag?.targetDate)
// Hold the landed chip until the write is done — including the whole time a // Hold the landed chip until the write is done — including the time a
// recurring drop's scope dialog is up — and then, unlike the timeline, carry // recurring drop's scope dialog is up — and then, unlike the timeline, carry
// it to its seat: a chip is dropped at whatever height the finger was at, // it to its seat: it was dropped at whatever height the finger was at, while
// while the grid seats it in a lane, so the copy has a gap to close before // the grid seats it in a lane.
// the two are the same chip and the hand-over can be invisible.
var handingOver by remember(controller.settling) { mutableStateOf(false) } var handingOver by remember(controller.settling) { mutableStateOf(false) }
var gliding by remember(controller.settling) { mutableStateOf(false) } var gliding by remember(controller.settling) { mutableStateOf(false) }
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) } val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
@@ -522,14 +518,13 @@ private fun MonthDragOverlay(controller: MonthDragController) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// A copy of a chip that is still in the tree behind it; announcing it // A copy of a chip still in the tree behind it; announcing it again
// again would just duplicate the event for the drag's duration. // would duplicate the event for the drag's duration.
.clearAndSetSemantics { } .clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() }, .onGloballyPositioned { origin = it.positionInRoot() },
) { ) {
val drag = controller.drag ?: controller.settling ?: return@Box val drag = controller.drag ?: controller.settling ?: return@Box
// Landed: the copy sinks back to the grid's own plane while the write // Landed: the copy sinks back to the grid's own plane while the write runs.
// runs, so the release reads as the chip settling rather than vanishing.
val lift by animateFloatAsState( val lift by animateFloatAsState(
targetValue = if (controller.drag == null || reduceMotion) 0f else 1f, targetValue = if (controller.drag == null || reduceMotion) 0f else 1f,
label = "chip-lift", label = "chip-lift",
@@ -1858,10 +1853,9 @@ private fun MonthWeekRow(
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
val morphing = morphInFlight() val morphing = morphInFlight()
// Drag to reschedule (#68). The chips can take no pointer input of their own // Drag to reschedule (#68). The chips can take no pointer input of their own
// the full-bleed tap layer below sits on top of them, and Compose stops // the full-bleed tap layer sits on top of them — so one detector on the
// sibling hit-testing at the topmost hit — so one detector on the row's day- // row's day-column box hit-tests them geometrically instead.
// column box hit-tests them geometrically instead.
val moveScope = LocalEventMove.current val moveScope = LocalEventMove.current
val dragController = LocalMonthDrag.current val dragController = LocalMonthDrag.current
val rowToken = remember { Any() } val rowToken = remember { Any() }
@@ -1874,10 +1868,9 @@ private fun MonthWeekRow(
DisposableEffect(rowToken, dragController) { DisposableEffect(rowToken, dragController) {
onDispose { dragController?.removeRow(rowToken) } onDispose { dragController?.removeRow(rowToken) }
} }
// Republished on layout *and* on every recomposition: the coordinates only // Republished on layout *and* on every recomposition: the coordinates change
// change on the former, but what the row draws — which the controller reads // only on the former, but what the row draws — which the controller reads to
// to find a moved chip's seat — changes on the latter, and a re-read that // find a moved chip's seat — changes on the latter.
// moves an event doesn't move the row it lands in.
val publish = { val publish = {
val cell = cellCoordinates[0]?.takeIf { it.isAttached } val cell = cellCoordinates[0]?.takeIf { it.isAttached }
if (dragController != null && cell != null) { if (dragController != null && cell != null) {
@@ -1898,9 +1891,8 @@ private fun MonthWeekRow(
} }
SideEffect { publish() } SideEffect { publish() }
// Once the grid holds the settled chip, tell the controller where this row // 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 // has seated it, so the copy in flight can glide onto it. Keyed on the week,
// because that is what changes when the re-read lands: until then the row // which is what changes when the re-read lands.
// still draws the event on the day it came from and answers nothing.
LaunchedEffect(week, dragController?.settling) { LaunchedEffect(week, dragController?.settling) {
dragController?.noteSettled(rowToken) dragController?.noteSettled(rowToken)
} }
@@ -2003,8 +1995,8 @@ private fun MonthWeekRow(
bandCoordinates[0] = it bandCoordinates[0] = it
publish() publish()
} }
// A dragged chip travels to another row, so the clip has // A dragged chip travels to another row, so the clip
// to yield for it exactly as it does for a morph. // yields for it as it does for a morph.
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()), .then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
) { ) {
// Spanning bars on their shared lanes. // Spanning bars on their shared lanes.
@@ -2167,10 +2159,9 @@ private fun MonthWeekRow(
} }
/** /**
* The row-level pickup for month chips: it resolves which chip the press landed * The row-level pickup for month chips: resolves which chip the press landed on
* on from the geometry the row just laid out, and abandons the gesture when the * from the geometry the row just laid out, and abandons the gesture on empty
* press was on empty space (or on an event whose calendar can't be moved), so * space (or an unmovable calendar) so tapping a day still opens it.
* tapping a day still opens it.
*/ */
@Composable @Composable
private fun monthChipDragModifier( private fun monthChipDragModifier(
@@ -2216,8 +2207,7 @@ private fun monthChipDragModifier(
onDrop = { onDrop = {
controller?.finish()?.let { drop -> controller?.finish()?.let { drop ->
// How many columns the finger crossed — grabbing the middle of a // How many columns the finger crossed — grabbing the middle of a
// multi-day bar shifts the event by what the finger travelled, not // multi-day bar shifts by what it travelled, not to where it landed.
// to where it landed.
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt() val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
if (delta != 0) { if (delta != 0) {
moveScope?.move( moveScope?.move(
@@ -2328,10 +2318,8 @@ private fun MonthBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/** /**
* The days this chip covers in its row, for the drag (#68). A drop's own chip * The days this chip covers in its row, for the drag (#68). A drop's own chip
* has to ghost like the source it came from until the copy has landed on it, * has to ghost until the copy lands on it, and the instance id can't find it:
* and the instance id can't find it: the provider hands the re-read instance a * the provider hands the re-read instance a new one.
* new one, so the chip the drop became would otherwise sit at full opacity
* under the copy still travelling towards it — the event drawn twice.
*/ */
days: List<LocalDate>? = null, days: List<LocalDate>? = null,
) { ) {
@@ -2341,8 +2329,7 @@ private fun MonthBar(
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften) val fill = eventFill(event.color, dark, soften)
val moveAction = eventMoveAction(event) val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels, then // The source stays put as a ghost while its floating copy travels.
// fades out as the copy settles on its new day.
val monthDrag = LocalMonthDrag.current val monthDrag = LocalMonthDrag.current
val lifted = monthDrag?.liftedInstanceId == event.instanceId || val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
monthDrag?.isSettledChip(event, days) == true monthDrag?.isSettledChip(event, days) == true

View File

@@ -69,9 +69,6 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
/** /**
* The event drawn in lane [lane] of column [col], or null for an empty slot. * The event drawn in lane [lane] of column [col], or null for an empty slot.
*
* Mirrors how the paged grid seats a week: spanning bars keep the lane the row
* layout gave them, and the day's timed pills fill the slots those bars leave.
* Unlike [laneEvents] the lanes are *not* compacted — this answers "what is under * Unlike [laneEvents] the lanes are *not* compacted — this answers "what is under
* this point", so an empty lane above a seated bar has to stay empty (#68). * this point", so an empty lane above a seated bar has to stay empty (#68).
*/ */

View File

@@ -349,8 +349,8 @@ private fun WeekContent(
// gestures coexist without fighting. // gestures coexist without fighting.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
// Above the AnimatedContent on purpose: a page change mid-drag would strand // Above the AnimatedContent: a page change mid-drag would strand the
// the floating block inside the outgoing page. // floating block inside the outgoing page.
val dragController = rememberTimelineDragController() val dragController = rememberTimelineDragController()
val move = LocalEventMove.current val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
@@ -711,8 +711,7 @@ private fun Timeline(
.fillMaxWidth() .fillMaxWidth()
.height(totalHeight) .height(totalHeight)
// The scrolling content itself, so its root position // The scrolling content itself, so its root position
// already folds in the scroll offset — a drag maps // already folds in the scroll offset.
// through it without reading the scroll state.
.onGloballyPositioned { coords -> .onGloballyPositioned { coords ->
val gap = with(density) { COLUMN_GAP.toPx() } val gap = with(density) { COLUMN_GAP.toPx() }
dragController.geometry.let { dragController.geometry.let {
@@ -797,8 +796,7 @@ private fun DayColumnCard(
val minEventHeight = hourHeight * MIN_EVENT_FRACTION val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same* // Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates // composable afterwards and tweens there. The ordinal disambiguates
// the rare column holding two occurrences of one series, which would // a column holding two occurrences of one series.
// otherwise be two blocks under one key.
val ordinals = mutableMapOf<Long, Int>() val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block -> blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
@@ -892,8 +890,8 @@ private fun EventBlock(
val fill = eventFill(block.event.color, dark, soften) val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge is // A block clipped at the top continues from the previous day: its top edge
// midnight, not the event's start, so dragging it would invent a time. // is midnight, not the event's start, so dragging it would invent a time.
val draggable = moveAction != null && block.beginsOn(date, zone) val draggable = moveAction != null && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
@@ -907,8 +905,7 @@ private fun EventBlock(
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels, // The source stays put as a ghost while its floating copy travels.
// then fades out as the copy settles on its new slot.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)

View File

@@ -288,9 +288,8 @@ class EventWriteMapperTest {
@Test @Test
fun `a weekday move must carry the rule with the anchor, or the series stays put`() { fun `a weekday move must carry the rule with the anchor, or the series stays put`() {
// The anchor moves by the same shift as the occurrence, so a Monday // The anchor moves by the same shift as the occurrence, while RRULE is
// series lands on a Wednesday — while RRULE is written verbatim. Without // written verbatim: without realignRecurrence nothing moves.
// realignRecurrence the rule would still say Monday and nothing moves.
val series = instantAt("2026-01-05T09:00", "Europe/Berlin") val series = instantAt("2026-01-05T09:00", "Europe/Berlin")
val original = form( val original = form(
start = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(9, 0)), start = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(9, 0)),

View File

@@ -108,8 +108,7 @@ class CalendarRowStateTest {
@Test @Test
fun `a switched-off calendar's events stay movable, unlike a new-event target`() { fun `a switched-off calendar's events stay movable, unlike a new-event target`() {
// Nothing renders them, so the question is moot — but the predicate is // The predicate is deliberately not isEventTarget.
// deliberately not isEventTarget, which would also exclude them.
val hidden = cal().copy(isVisibleInSystem = false) val hidden = cal().copy(isVisibleInSystem = false)
assertThat(hidden.isEventTarget).isFalse() assertThat(hidden.isEventTarget).isFalse()
assertThat(hidden.allowsEventMove).isTrue() assertThat(hidden.allowsEventMove).isTrue()

View File

@@ -67,8 +67,8 @@ class EventShiftTest {
val moved = original.shiftedTo(target, berlin) val moved = original.shiftedTo(target, berlin)
// Two real hours from 01:00 CET lands at 04:00 CEST, not 03:00: the wall // Two real hours from 01:00 CET lands at 04:00 CEST: the hour in
// clock stretches because the hour in between does not exist. // between does not exist.
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0))) assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0))) assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin)) assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
@@ -131,9 +131,8 @@ class EventShiftTest {
@Test @Test
fun `a timed shift onto a DST changeover keeps the real length, not the wall clock`() { fun `a timed shift onto a DST changeover keeps the real length, not the wall clock`() {
// 22:00 Sat -> 04:00 Sun is six real hours; the target Sunday is the one // 22:00 Sat -> 04:00 Sun is six real hours, onto the Sunday Berlin
// Berlin springs forward on. Keeping wall clock would write a five-hour // springs forward on. Keeping wall clock would write five.
// DURATION for the whole series.
val overnight = form( val overnight = form(
start = LocalDateTime(LocalDate(2026, 3, 21), LocalTime(22, 0)), start = LocalDateTime(LocalDate(2026, 3, 21), LocalTime(22, 0)),
end = LocalDateTime(LocalDate(2026, 3, 22), LocalTime(4, 0)), end = LocalDateTime(LocalDate(2026, 3, 22), LocalTime(4, 0)),

View File

@@ -26,8 +26,7 @@ class RecurrenceRealignTest {
fun `a day-of-month rule is refused, because the anchor moves by days not dates`() { fun `a day-of-month rule is refused, because the anchor moves by days not dates`() {
// BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1: // BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1:
// the rebuilt rule would say the 1st while the anchor became Jan 29 — a // the rebuilt rule would say the 1st while the anchor became Jan 29 — a
// DTSTART that is not an instance of its own rule. Weekday arithmetic is // DTSTART that is not an instance of its own rule.
// uniform mod 7 and survives the same shift; day-of-month is not.
assertThat(realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday)).isNull() assertThat(realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday)).isNull()
assertThat( assertThat(
realignRecurrence("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", monday, LocalDate(2026, 7, 20)), realignRecurrence("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", monday, LocalDate(2026, 7, 20)),
@@ -36,8 +35,7 @@ class RecurrenceRealignTest {
@Test @Test
fun `BYDAY on a non-weekly rule is refused`() { fun `BYDAY on a non-weekly rule is refused`() {
// "every Monday of the month" is a shape this has not been reasoned about, // parseSimpleRecurrence can't read it either, so the UNTIL guard
// and parseSimpleRecurrence can't read it either — so the UNTIL guard
// downstream would be blind to it. // downstream would be blind to it.
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO", monday, wednesday)).isNull() assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO", monday, wednesday)).isNull()
} }
@@ -86,8 +84,7 @@ class RecurrenceRealignTest {
@Test @Test
fun `everything realignable is also a rule the UNTIL guard can read`() { fun `everything realignable is also a rule the UNTIL guard can read`() {
// problems() checks UNTIL through parseSimpleRecurrence; a rule this // problems() checks UNTIL through parseSimpleRecurrence; a rule this
// realigns but that parser rejects would move a series past its own end // realigns but that parser rejects would go unchecked.
// unchecked. Accepting only weekly BYDAY keeps the two in step.
val realignable = listOf( val realignable = listOf(
"FREQ=WEEKLY;BYDAY=MO", "FREQ=WEEKLY;BYDAY=MO",
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;UNTIL=20261231T225959Z", "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;UNTIL=20261231T225959Z",

View File

@@ -45,10 +45,8 @@ class RescheduleViewModelTest {
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher) @BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain() @AfterEach fun tearDown() = Dispatchers.resetMain()
// Monday 2026-06-08, midday in whatever zone the test JVM runs in — resolved // Monday 2026-06-08, midday in the device zone the zone the drop path
// in the device zone rather than UTC, because that is the zone the drop path // reads dates back in. The BYDAY assertions depend on it being a Monday.
// reads dates back in, and at UTC+13 a UTC midday is already Tuesday. The
// BYDAY assertions below depend on this really being a Monday.
private val monday = LocalDate(2026, 6, 8) private val monday = LocalDate(2026, 6, 8)
private val beginMillis = LocalDateTime(monday, LocalTime(12, 0)) private val beginMillis = LocalDateTime(monday, LocalTime(12, 0))
.toInstant(TimeZone.currentSystemDefault()) .toInstant(TimeZone.currentSystemDefault())
@@ -282,8 +280,7 @@ class RescheduleViewModelTest {
vm.move(toWednesday()) vm.move(toWednesday())
advanceUntilIdle() advanceUntilIdle()
// The block belongs to the write, not to the drop: an exception row is // An exception row is constrained by no UNTIL, so the scope stays on offer.
// constrained by no UNTIL, so this scope must still be on offer.
assertThat(vm.scopePrompt.value).isNotNull() assertThat(vm.scopePrompt.value).isNotNull()
vm.moveWithScope(RecurringWriteScope.ThisEvent) vm.moveWithScope(RecurringWriteScope.ThisEvent)
@@ -302,9 +299,8 @@ class RescheduleViewModelTest {
} }
val vm = viewModel(tempDir, fake) val vm = viewModel(tempDir, fake)
// Onto the next day *and* two hours later: the anchor moves by the same // Onto the next day *and* two hours later: a late enough anchor would
// wall-clock shift, so an anchor late enough in the day would cross two // cross two midnights under the same wall-clock shift.
// midnights and land on a weekday the rebuilt rule doesn't name.
vm.move( vm.move(
MoveRequest( MoveRequest(
eventId = 42L, eventId = 42L,