Fix drag-reschedule issues found in review (#68)

Write path:

- realignRecurrence now handles weekly BYDAY only. What the rule must agree
  with is the series *anchor*, which moves by the same wall-clock shift as the
  dragged occurrence — weekday survives that (uniform mod 7), day-of-month does
  not. BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1,
  produced a Jan 29 anchor under a BYMONTHDAY=1 rule: a DTSTART that is not an
  instance of its own rule. Also refuse when the shift is not a whole number of
  days, which would carry a late-enough anchor across an extra midnight.
- The accepted parts are now a subset of parseSimpleRecurrence's, so anything
  realignable is a rule the UNTIL guard can actually read. FREQ=MONTHLY;BYDAY=MO
  previously slipped past it and could move a series past its own end.
- The UNTIL check moved to write time and picks the date the chosen scope
  actually starts at: the anchor for a whole-series move, the occurrence for a
  split, nothing for a single occurrence. It used to test the occurrence in
  every case and refused the ordinary drag of a bounded series' last one.
- shiftedByDays preserves the instant duration for timed events, as shiftedTo
  already did — a month drag onto a DST changeover rewrote the whole series'
  DURATION.
- Guard against a second drop landing while one is in flight; the shifts would
  compound on the re-read anchor.

Gestures:

- A long press that never moved wrote a reschedule: the target snaps to the
  15-minute grid at pickup, so an event at 09:07 resolved to 09:00 the instant
  it lifted. The drop is now refused when it lands on the slot it started from.
- After pickup the gesture is driven and consumed on the initial pass. Pinching
  mid-drag used to zoom and drag at once, then write wherever the zoom left the
  block; the month grid's tap layer opened the day on lift.
- RTL: pointer x was mapped to columns as if the grid were LTR, so a drop landed
  6-n columns away, and both ghosts used the direction-aware offset with root
  coordinates, mirroring them across the screen.
- The month drop passes the day delta instead of a date, so the grid and the
  view model no longer each resolve the event's first day in their own zone.
This commit is contained in:
2026-08-01 17:42:31 +02:00
parent aa24de443d
commit cf0540fce4
14 changed files with 401 additions and 110 deletions

View File

@@ -40,13 +40,24 @@ fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
/**
* The form moved [days] calendar days, keeping its time of day and its length.
* Both ends move, so a multi-day event keeps its span rather than collapsing to
* a single day; an all-day event's placeholder times ride along untouched.
* A multi-day event keeps its span rather than collapsing to a single day.
*
* 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): EventForm {
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
if (days == 0) return this
return copy(
start = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time),
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
)
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
if (isAllDay) {
return copy(
start = newStart,
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
)
}
val zone = resolvedZone(deviceZone)
val span = end.toInstant(zone) - start.toInstant(zone)
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
}

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.number
/**
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
@@ -9,41 +8,55 @@ import kotlinx.datetime.number
* `Events.RRULE` is written verbatim while DTSTART moves, so a rule that names
* its own day — `FREQ=WEEKLY;BYDAY=MO`, what Google and CalDAV write for nearly
* every weekly series — would keep pointing at Monday after the anchor became a
* Wednesday, and the series would not move at all. The day-selecting parts are
* therefore re-derived from [newStart].
* Wednesday, and the series would not move at all. `BYDAY` is therefore
* re-derived from [newStart].
*
* Returns null when the rule picks days in a way one moved occurrence cannot
* resolve (`BYDAY=MO,WE`, an ordinal `2TH`, `BYSETPOS`, …). There is no safe
* guess there — dropping the extra days would delete occurrences — so the caller
* refuses to move anything but that single occurrence.
* **Only weekly `BYDAY` is realigned, and only for a whole-day move.** The rule
* has to agree with the series *anchor*, which is not the occurrence being
* dragged: the anchor moves by the same wall-clock shift, and only weekday
* arithmetic survives that unchanged, because it is uniform mod 7 and every
* anchor time-of-day crosses the same number of midnights. Day-of-month does not
* — `BYMONTHDAY=28` on a January anchor dragged from Feb 28 to Mar 1 gives an
* 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? {
if (oldStart == newStart) return rrule
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
if (parts.isEmpty()) return null
var weekly = false
val rebuilt = parts.map { part ->
val eq = part.indexOf('=')
if (eq <= 0) return null
val key = part.substring(0, eq).uppercase()
val value = part.substring(eq + 1).trim()
when (key) {
"FREQ" -> {
weekly = value.equals("WEEKLY", ignoreCase = true)
part
}
"BYDAY" -> {
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
if (!value.equals(old, ignoreCase = true)) return null
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
}
"BYMONTHDAY" -> {
if (value != oldStart.day.toString()) return null
"BYMONTHDAY=${newStart.day}"
}
"BYMONTH" -> {
if (value != oldStart.month.number.toString()) return null
"BYMONTH=${newStart.month.number}"
}
"FREQ", "INTERVAL", "COUNT", "UNTIL", "WKST" -> part
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
else -> return null
}
}
// 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 (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
return prefix + rebuilt.joinToString(";")
}

View File

@@ -121,15 +121,25 @@ fun rememberDragSurface(
if (!took) return@awaitEachGesture
var dropped = false
try {
// Once the block is lifted the gesture is ours, so it is
// driven on the initial pass and consumed there. That pass
// runs parent → child, which gets both halves right: an
// ancestor that outranks us (the pinch, which claims the
// 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) {
val event = awaitPointerEvent()
val event = awaitPointerEvent(PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (change.isConsumed) break
if (!change.pressed) {
change.consume()
dropped = true
break
}
// A second finger is the pinch taking over; it consumes on
// the initial pass, so the drag is already dead.
if (event.changes.count { it.pressed } > 1) break
change.consume()
coordinates[0]?.takeIf { it.isAttached }

View File

@@ -55,7 +55,7 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
val moved = outcome as? MoveOutcome.Moved
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
val message = when (val o = outcome) {
val message = when (outcome) {
null -> null
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)

View File

@@ -6,10 +6,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.allowsEventMove
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resolvedZone
@@ -26,9 +29,11 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
@@ -40,8 +45,14 @@ sealed interface MoveTarget {
/** A new start instant — a timeline drag, which moves time and day at once. */
data class Start(val instant: Instant) : MoveTarget
/** A new first day, keeping the time of day — a month-grid or all-day drag. */
data class Day(val date: LocalDate) : MoveTarget
/**
* 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
* many columns the finger crossed, and re-deriving that from a date would
* 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
}
/**
@@ -122,6 +133,14 @@ class RescheduleViewModel @Inject constructor(
private var pending: PreparedMove? = null
/**
* 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
* their shift from the same pre-move occurrence, and the data layer applies
* both to the re-read anchor — so the shifts would compound.
*/
private var busy = false
/**
* The calendars whose events may be dragged. Nothing below the UI guards
* this — the repository and data source attempt any write handed to them —
@@ -149,15 +168,24 @@ class RescheduleViewModel @Inject constructor(
* wider than the single occurrence would leave rule and anchor disagreeing.
*/
val canRealign: Boolean,
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
val newAnchorDate: LocalDate,
)
fun move(request: MoveRequest) {
if (_scopePrompt.value != null) return
if (busy || _scopePrompt.value != null) return
busy = true
viewModelScope.launch {
val prepared = prepare(request) ?: return@launch
val prepared = prepare(request)
if (prepared == null) {
busy = false
return@launch
}
if (!prepared.isRecurring) {
write(prepared, RecurringWriteScope.AllEvents)
busy = false
} else {
// Still busy: the scope dialog is now the thing in flight.
pending = prepared
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
}
@@ -167,19 +195,28 @@ class RescheduleViewModel @Inject constructor(
/** Answer the scope dialog. */
fun moveWithScope(scope: RecurringWriteScope) {
val prepared = pending ?: return
// Belt and braces against the dialog ever offering a scope the rule
// can't carry: writing it would leave anchor and rule disagreeing.
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
pending = null
_scopePrompt.value = null
viewModelScope.launch { write(prepared, scope) }
viewModelScope.launch {
write(prepared, scope)
busy = false
}
}
/** Dismiss the scope dialog without writing. */
fun cancelScope() {
pending = null
busy = false
_scopePrompt.value = null
}
/** Put a completed move back where it came from. */
fun undo(undo: MoveUndo) {
if (busy) return
busy = true
_outcome.value = null
viewModelScope.launch {
_outcome.value = try {
@@ -194,6 +231,7 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
busy = false
}
}
@@ -219,23 +257,16 @@ class RescheduleViewModel @Inject constructor(
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.Day -> original.shiftedByDays(
(
target.date.toEpochDays() -
sourceDate(request.beginMillis, original.isAllDay, zone).toEpochDays()
).toInt(),
)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
}
// A zero-distance drop is not a write. Matches the edit form's own
// pristine-form no-op, and keeps a mis-aimed long press harmless.
if (shifted == original) return null
val problems = shifted.problems()
if (EventFormProblem.RecurrenceEndsBeforeStart in problems) {
_outcome.value = MoveOutcome.BlockedSeriesEnd
return null
}
if (problems.isNotEmpty()) {
// The UNTIL check is deferred to the write: how far the move reaches
// decides which date has to clear it, and "only this event" writes an
// exception row that no UNTIL constrains at all.
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
_outcome.value = MoveOutcome.Failed
return null
}
@@ -243,12 +274,23 @@ class RescheduleViewModel @Inject constructor(
// An exception row stands alone whatever rule a sync adapter left on it.
val isRecurring = original.rrule != null && !detail.isException
val movedDay = shifted.start.date != original.start.date
// 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
// of days — a drag that also changes the time of day would carry some
// 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 realigned = if (isRecurring && movedDay) {
realignRecurrence(
requireNotNull(original.rrule),
original.start.date,
shifted.start.date,
)
if (wholeDayShift) {
realignRecurrence(
requireNotNull(original.rrule),
original.start.date,
shifted.start.date,
)
} else {
null
}
} else {
original.rrule
}
@@ -260,11 +302,20 @@ class RescheduleViewModel @Inject constructor(
updated = shifted.copy(rrule = realigned ?: original.rrule),
isRecurring = isRecurring,
canRealign = !isRecurring || !movedDay || realigned != null,
// Where the series row's own DTSTART lands, given the anchor moves by
// the same shift. Only meaningful under [wholeDayShift], which is the
// only case a wider-than-one-occurrence write is offered in.
newAnchorDate = anchorDate(detail, original, zone)
.plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY),
)
}
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) {
_outcome.value = MoveOutcome.BlockedSeriesEnd
return
}
_outcome.value = try {
when {
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
@@ -300,6 +351,39 @@ class RescheduleViewModel @Inject constructor(
}
}
/**
* 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
* silently disappears from every view.
*
* Which date has to clear `UNTIL` depends on how far the write reaches: a
* 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 {
if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false
val rule = prepared.updated.rrule ?: return false
val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false
val firstDay = if (scope == RecurringWriteScope.AllEvents) {
prepared.newAnchorDate
} else {
prepared.updated.start.date
}
return end.date < firstDay
}
/**
* The series row's own start date — for a recurring master, `EventDetail`
* carries the row's DTSTART rather than the tapped occurrence's. Read in the
* same anchoring the write path uses: UTC for an all-day series.
*/
private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate =
detail.instance.start
.toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone))
.date
/**
* Undo is offered only where the inverse is one symmetric write: a
* non-recurring event (absolute DTSTART/DTEND) and a whole-series move (the
@@ -333,9 +417,4 @@ class RescheduleViewModel @Inject constructor(
}
}
/** The calendar day the dragged occurrence started on, as the grid shows it. */
private fun sourceDate(beginMillis: Long, isAllDay: Boolean, zone: TimeZone): LocalDate =
Instant.fromEpochMilliseconds(beginMillis)
.toLocalDateTime(if (isAllDay) TimeZone.UTC else zone)
.date
}

View File

@@ -7,7 +7,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -26,6 +26,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
@@ -95,6 +96,13 @@ class TimelineGeometry {
var edgePx: Float = 0f
var stepPx: Float = 0f
var days: List<LocalDate> = emptyList()
/**
* Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored, but the grid is, so the leftmost column is the *last* day
* in Arabic — the mapping has to flip with it.
*/
var isRtl: Boolean = false
}
/**
@@ -123,6 +131,14 @@ class TimelineDragController {
private var grab = Offset.Zero
private var pointer = Offset.Zero
/**
* The slot the block already occupied when it was picked up. A long press
* that never moves must write nothing — and because the target snaps to the
* 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
val isDragging: Boolean get() = liftedInstanceId != null
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
@@ -130,7 +146,9 @@ class TimelineDragController {
liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot
pointer = pointerInRoot
originSlot = null
recompute()
originSlot = drag?.slot
}
fun move(pointerInRoot: Offset) {
@@ -141,14 +159,20 @@ class TimelineDragController {
fun cancel() {
source = null
liftedInstanceId = null
originSlot = null
drag = null
}
/** End the drag, handing back where it landed (null if it never resolved). */
/**
* End the drag, handing back where it landed — null when it never resolved,
* or when it landed back on the slot it started from.
*/
fun finish(): TimelineDrop? {
val landed = drag
val origin = originSlot
cancel()
return landed?.let { TimelineDrop(it.event, it.date, it.startMin) }
if (landed == null || landed.slot == origin) return null
return TimelineDrop(landed.event, landed.date, landed.startMin)
}
/**
@@ -171,11 +195,13 @@ class TimelineDragController {
// fine and stays visible on the next day.
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val span = block.endMin - block.startMin
// The column the finger is over on screen, and the day that column shows.
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
drag = TimelineDrag(
event = block.event,
date = days[column],
date = days[dayIndex],
startMin = startMin,
endMin = startMin + span,
topLeftInRoot = Offset(
@@ -270,6 +296,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
Box(
modifier = modifier
.fillMaxSize()
// A copy of a block that is still in the tree behind it; announcing
// it again would just duplicate the event for the drag's duration.
.clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() },
) {
val drag = controller.drag ?: return@Box
@@ -279,7 +308,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
formatMinuteOfDay(drag.endMin.coerceAtMost(MINUTES_PER_DAY), use24Hour, locale)
Box(
modifier = Modifier
.offset {
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
IntOffset(
(drag.topLeftInRoot.x - origin.x).roundToInt(),
(drag.topLeftInRoot.y - origin.y).roundToInt(),

View File

@@ -58,12 +58,14 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -536,6 +538,7 @@ private fun Timeline(
val locale = currentLocale()
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
@@ -612,6 +615,7 @@ private fun Timeline(
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
it.isRtl = isRtl
}
},
)
@@ -727,15 +731,16 @@ private fun EventBlock(
val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge is
// midnight, not the event's start, so dragging it would invent a time.
val draggable = moveAction != null && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource(
enabled = moveAction != null && block.beginsOn(date, zone),
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = dragController.liftedInstanceId == block.event.instanceId
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
Box(
modifier = modifier
// The source stays put as a ghost while its floating copy travels.

View File

@@ -20,6 +20,12 @@ class MonthRowGeometry(
/** The row's day-column box — the space chip offsets are measured in. */
val cell: LayoutCoordinates,
val columnWidthPx: Float,
/**
* Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored, but the grid is, so the leftmost column is the *last* day
* in Arabic.
*/
val isRtl: Boolean,
)
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
@@ -127,7 +133,7 @@ class MonthDragController {
val column = ((pointer.x - bounds.left) / row.columnWidthPx)
.toInt()
.coerceIn(0, row.days.lastIndex)
row.days[column]
row.days[if (row.isRtl) row.days.lastIndex - column else column]
}
}
drag = MonthChipDrag(

View File

@@ -78,6 +78,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.geometry.Offset
import kotlin.math.roundToInt
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
@@ -472,6 +476,9 @@ private fun MonthDragOverlay(controller: MonthDragController) {
Box(
modifier = Modifier
.fillMaxSize()
// A copy of a chip that is still in the tree behind it; announcing it
// again would just duplicate the event for the drag's duration.
.clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() },
) {
val drag = controller.drag ?: return@Box
@@ -481,7 +488,9 @@ private fun MonthDragOverlay(controller: MonthDragController) {
continuesLeft = false,
continuesRight = false,
modifier = Modifier
.offset {
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
IntOffset(
(drag.topLeftInRoot.x - origin.x).roundToInt(),
(drag.topLeftInRoot.y - origin.y).roundToInt(),
@@ -1802,7 +1811,7 @@ private fun MonthWeekRow(
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val density = LocalDensity.current
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
val zone = remember { TimeZone.currentSystemDefault() }
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val dragging = dragController?.isDragging == true
DisposableEffect(rowToken, dragController) {
onDispose { dragController?.removeRow(rowToken) }
@@ -1831,6 +1840,7 @@ private fun MonthWeekRow(
days = week.days,
cell = coords,
columnWidthPx = coords.size.width / 7f,
isRtl = isRtl,
),
)
}
@@ -1841,7 +1851,7 @@ private fun MonthWeekRow(
controller = dragController,
band = bandCoordinates,
rowHeightPx = rowHeightPx,
zone = zone,
isRtl = isRtl,
),
),
) {
@@ -2083,7 +2093,7 @@ private fun monthChipDragModifier(
controller: MonthDragController?,
band: Array<LayoutCoordinates?>,
rowHeightPx: Float,
zone: TimeZone,
isRtl: Boolean,
): Modifier = rememberDragSurface(
enabled = moveScope != null && controller != null,
key = week.days.first(),
@@ -2092,18 +2102,20 @@ private fun monthChipDragModifier(
val columnPx = size.width / 7f
val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y)
val lane = (bandY / rowHeightPx).toInt()
// The column under the finger on screen, and the day that column shows.
val column = (local.x / columnPx).toInt()
val dayIndex = if (isRtl) week.days.lastIndex - column else column
val event = if (bandY < 0f || columnPx <= 0f) {
null
} else {
week.chipAt(column, lane, MAX_EVENT_ROWS)
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
}
if (event == null || moveScope?.allows(event) != true) {
false
} else {
controller?.begin(
event = event,
grabDate = week.days[column],
grabDate = week.days[dayIndex],
pointerInRoot = pointerInRoot,
chipInRoot = Offset(
x = nodeInRoot.x + column * columnPx,
@@ -2117,19 +2129,17 @@ private fun monthChipDragModifier(
onMove = { controller?.move(it) },
onDrop = {
controller?.finish()?.let { drop ->
val delta = drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()
if (delta != 0L) {
// How many columns the finger crossed — grabbing the middle of a
// multi-day bar shifts the event by what the finger travelled, not
// to where it landed.
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
if (delta != 0) {
moveScope?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
// The event's own first day plus the columns crossed —
// grabbing the middle of a multi-day bar shifts it by
// what the finger travelled, not to where it landed.
target = MoveTarget.Day(
drop.event.spanFirstDay(zone).plus(delta.toInt(), DateTimeUnit.DAY),
),
target = MoveTarget.ByDays(delta),
),
)
}

View File

@@ -64,6 +64,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
@@ -71,6 +72,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -672,6 +674,7 @@ private fun Timeline(
val locale = currentLocale()
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
@@ -742,6 +745,7 @@ private fun Timeline(
it.columnGapPx = gap
it.columnWidthPx = (coords.size.width + gap) / state.days.size
it.days = state.days
it.isRtl = isRtl
}
},
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
@@ -895,15 +899,16 @@ private fun EventBlock(
val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge is
// midnight, not the event's start, so dragging it would invent a time.
val draggable = moveAction != null && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource(
enabled = moveAction != null && block.beginsOn(date, zone),
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = dragController.liftedInstanceId == block.event.instanceId
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels.

View File

@@ -109,7 +109,7 @@ class EventShiftTest {
assertThat(allDay.shiftedTo(target, berlin)).isEqualTo(allDay)
val moved = allDay.shiftedByDays(3)
val moved = allDay.shiftedByDays(3, berlin)
assertThat(moved.start.date).isEqualTo(LocalDate(2026, 6, 14))
assertThat(moved.end.date).isEqualTo(LocalDate(2026, 6, 14))
// The placeholder times exist only for a switch back to timed; untouched.
@@ -123,15 +123,34 @@ class EventShiftTest {
end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(2, 0)),
)
val moved = multiDay.shiftedByDays(-2)
val moved = multiDay.shiftedByDays(-2, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
}
@Test
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
// Berlin springs forward on. Keeping wall clock would write a five-hour
// DURATION for the whole series.
val overnight = form(
start = LocalDateTime(LocalDate(2026, 3, 21), LocalTime(22, 0)),
end = LocalDateTime(LocalDate(2026, 3, 22), LocalTime(4, 0)),
)
val moved = overnight.shiftedByDays(7, berlin)
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
.isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin))
// The wall-clock end therefore lands an hour later than a naive +7 days.
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
}
@Test
fun `shifting by no days is a no-op`() {
val original = form()
assertThat(original.shiftedByDays(0)).isEqualTo(original)
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
}
}

View File

@@ -23,21 +23,28 @@ class RecurrenceRealignTest {
}
@Test
fun `a monthly BYMONTHDAY rule follows the day of the month`() {
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:
// 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
// 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),
).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=10")
realignRecurrence("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", monday, LocalDate(2026, 7, 20)),
).isNull()
}
@Test
fun `a yearly rule realigns both month and day`() {
assertThat(
realignRecurrence(
"FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8",
monday,
LocalDate(2026, 7, 20),
),
).isEqualTo("FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=20")
fun `BYDAY on a non-weekly rule is refused`() {
// "every Monday of the month" is a shape this has not been reasoned about,
// and parseSimpleRecurrence can't read it either — so the UNTIL guard
// downstream would be blind to it.
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO", monday, wednesday)).isNull()
}
@Test
fun `a rule with no FREQ is refused`() {
assertThat(realignRecurrence("INTERVAL=2;BYDAY=MO", monday, wednesday)).isNull()
}
@Test
@@ -71,11 +78,28 @@ class RecurrenceRealignTest {
@Test
fun `parts we cannot reason about are refused rather than guessed`() {
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1", monday, wednesday))
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO;BYSETPOS=1", monday, wednesday))
.isNull()
assertThat(realignRecurrence("FREQ=YEARLY;BYYEARDAY=159", monday, wednesday)).isNull()
}
@Test
fun `everything realignable is also a rule the UNTIL guard can read`() {
// problems() checks UNTIL through parseSimpleRecurrence; a rule this
// realigns but that parser rejects would move a series past its own end
// unchecked. Accepting only weekly BYDAY keeps the two in step.
val realignable = listOf(
"FREQ=WEEKLY;BYDAY=MO",
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;UNTIL=20261231T225959Z",
"FREQ=DAILY;COUNT=5",
)
realignable.forEach { rule ->
assertThat(realignRecurrence(rule, monday, wednesday)).isNotNull()
assertThat(parseSimpleRecurrence(realignRecurrence(rule, monday, wednesday)!!))
.isNotNull()
}
}
@Test
fun `a leading RRULE prefix is preserved`() {
assertThat(realignRecurrence("RRULE:FREQ=WEEKLY;BYDAY=MO", monday, wednesday))

View File

@@ -45,12 +45,14 @@ class RescheduleViewModelTest {
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
// Monday 2026-06-08, midday UTC — midday so the device zone can't move the
// weekday out from under the BYDAY assertions.
// Monday 2026-06-08, midday in whatever zone the test JVM runs in — resolved
// in the device zone rather than UTC, because that is the zone the drop path
// 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 wednesday = LocalDate(2026, 6, 10)
private val beginMillis =
LocalDateTime(monday, LocalTime(12, 0)).toInstant(TimeZone.UTC).toEpochMilliseconds()
private val beginMillis = LocalDateTime(monday, LocalTime(12, 0))
.toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
private val endMillis = beginMillis + 3_600_000L
private fun cal(
@@ -103,7 +105,7 @@ class RescheduleViewModelTest {
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Day(wednesday),
target = MoveTarget.ByDays(2),
)
/** A drop an hour later, expressed the way a timeline drag expresses one. */
@@ -262,13 +264,62 @@ class RescheduleViewModelTest {
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd)
assertThat(vm.scopePrompt.value).isNull()
assertThat(fake.updatedEvents).isEmpty()
}
@Test
fun `dragging a bounded series' last occurrence still allows moving just it`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") }
}
val vm = viewModel(tempDir, fake)
vm.move(toWednesday())
advanceUntilIdle()
// The block belongs to the write, not to the drop: an exception row is
// constrained by no UNTIL, so this scope must still be on offer.
assertThat(vm.scopePrompt.value).isNotNull()
vm.moveWithScope(RecurringWriteScope.ThisEvent)
advanceUntilIdle()
assertThat(fake.updatedOccurrences).hasSize(1)
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
}
@Test
fun `a drag that changes both the day and the time cannot move the series`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
}
val vm = viewModel(tempDir, fake)
// Onto the next day *and* two hours later: the anchor moves by the same
// wall-clock shift, so an anchor late enough in the day would cross two
// midnights and land on a weekday the rebuilt rule doesn't name.
vm.move(
MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
),
),
)
advanceUntilIdle()
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
}
@Test
fun `a zero-distance drop writes nothing and says nothing`(
@TempDir tempDir: Path,

View File

@@ -123,21 +123,45 @@ Two things the drag has to get right that the edit screen sidesteps:
- **`RRULE` day parts go stale.** `buildEventUpdateValues` writes the rule
verbatim while `DTSTART` moves, so dragging a `FREQ=WEEKLY;BYDAY=MO` occurrence
onto a Wednesday under *All events* leaves a Wednesday anchor under a Monday
rule and the series does not move. `realignRecurrence` re-derives `BYDAY` /
`BYMONTHDAY` / `BYMONTH` from the new date, and returns **null** for rules one
moved occurrence cannot resolve (`BYDAY=MO,WE`, `2TH`, `BYSETPOS`) — the drop
then offers only *this event*, whose exception row carries no rule at all. The
same staleness is reachable from the edit screen; wiring it there too is a
separate change.
rule and the series does not move. `realignRecurrence` re-derives `BYDAY`, and
returns **null** for everything else — the drop then offers only *this event*,
whose exception row carries no rule at all. The same staleness is reachable
from the edit screen; wiring it there too is a separate change.
What the rule has to agree with is the **series anchor**, not the occurrence
being dragged, and the anchor moves by the same *wall-clock* shift. Only two
things survive that intact, which is exactly the envelope the realigner accepts:
weekday (uniform mod 7, so every anchor time of day crosses the same number of
midnights) and a shift that is a whole number of days (otherwise a late-enough
anchor crosses one midnight more than the occurrence did). Day-of-month is not
uniform — `BYMONTHDAY=28` with a January anchor, occurrence Feb 28 dragged to
Mar 1, would leave a Jan 29 anchor under a `BYMONTHDAY=1` rule, a DTSTART that
is not an instance of its own rule and a phantom occurrence on any client that
trusts it. Those drags may only move the one occurrence.
The accepted parts are deliberately a **subset** of what `parseSimpleRecurrence`
understands, so anything realignable is also a rule whose `UNTIL` the guard
below can actually read.
- **The eligibility gate is load-bearing.** Nothing below the UI refuses a
write, so `CalendarSource.allowsEventMove` is what keeps read-only and
contact-managed events from being dragged. It is deliberately *not*
`isEventTarget`: a managed event is editable (reminders, notes) yet must never
move, while a switched-off calendar renders nothing to grab anyway.
`EventForm.problems()` runs before the write, so a drop that would push a series
past its own `UNTIL` — which makes the provider generate zero occurrences and the
event vanish — is refused rather than written.
A drop that would push a series past its own `UNTIL` — the provider then
generates zero occurrences and the event vanishes from every view — is refused
rather than written. The check happens at **write** time, not at drop time,
because which date has to clear `UNTIL` depends on how far the write reaches: a
whole-series move carries the *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.
Two known limitations of a whole-series move, both shared with the edit screen's
own *All events* time save rather than introduced here — a drag just makes them
one gesture away: the series' `EXDATE` stamps and its exception rows are **not**
re-anchored, so previously deleted occurrences can reappear and previously
modified ones stay behind while the rest of the series moves.
### Event time zones
@@ -186,7 +210,10 @@ shape (`DURATION` normalises to `P<n>S`/`P<n>D`, `EVENT_TIMEZONE` is stamped
concrete), and it is offered only where the inverse is one symmetric write — a
one-off event or a whole-series shift. *This event* leaves an exception row
behind and *this and following* splits the series; neither is undone by shifting
back, so both get a plain confirmation.
back, so both get a plain confirmation. Two further gaps, both narrow and
accepted: a shift whose *anchor* crosses a DST gap is not invertible in wall
clock (the −Δ normalises back to where it started), and an undo after a
concurrent remote time change overwrites it, exactly as the forward move would.
## Reminder delivery