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. * 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 multi-day event keeps its span rather than collapsing to a single day.
* a single day; an all-day event's placeholder times ride along untouched. *
* 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 if (days == 0) return this
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
if (isAllDay) {
return copy( return copy(
start = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time), start = newStart,
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time), 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 package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.number
/** /**
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart]. * [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 * `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 * 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 * 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 * Wednesday, and the series would not move at all. `BYDAY` is therefore
* therefore re-derived from [newStart]. * re-derived from [newStart].
* *
* Returns null when the rule picks days in a way one moved occurrence cannot * **Only weekly `BYDAY` is realigned, and only for a whole-day move.** The rule
* resolve (`BYDAY=MO,WE`, an ordinal `2TH`, `BYSETPOS`, …). There is no safe * has to agree with the series *anchor*, which is not the occurrence being
* guess there — dropping the extra days would delete occurrences — so the caller * dragged: the anchor moves by the same wall-clock shift, and only weekday
* refuses to move anything but that single occurrence. * 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? { fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
if (oldStart == newStart) return rrule if (oldStart == newStart) return rrule
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else "" val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() } val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
if (parts.isEmpty()) return null if (parts.isEmpty()) return null
var weekly = false
val rebuilt = parts.map { part -> val rebuilt = parts.map { part ->
val eq = part.indexOf('=') val eq = part.indexOf('=')
if (eq <= 0) return null if (eq <= 0) return null
val key = part.substring(0, eq).uppercase() val key = part.substring(0, eq).uppercase()
val value = part.substring(eq + 1).trim() val value = part.substring(eq + 1).trim()
when (key) { when (key) {
"FREQ" -> {
weekly = value.equals("WEEKLY", ignoreCase = true)
part
}
"BYDAY" -> { "BYDAY" -> {
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
if (!value.equals(old, ignoreCase = true)) return null if (!value.equals(old, ignoreCase = true)) return null
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}" "BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
} }
"BYMONTHDAY" -> { "INTERVAL", "COUNT", "UNTIL", "WKST" -> part
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
else -> return null 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(";") return prefix + rebuilt.joinToString(";")
} }

View File

@@ -121,15 +121,25 @@ 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 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) { while (true) {
val event = awaitPointerEvent() 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
if (change.isConsumed) break
if (!change.pressed) { if (!change.pressed) {
change.consume()
dropped = true dropped = true
break 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 if (event.changes.count { it.pressed } > 1) break
change.consume() change.consume()
coordinates[0]?.takeIf { it.isAttached } 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 moved = outcome as? MoveOutcome.Moved
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) } val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
val message = when (val o = outcome) { val message = when (outcome) {
null -> null null -> null
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty()) is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
MoveOutcome.Undone -> stringResource(R.string.event_move_undone) 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.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.allowsEventMove import de.jeanlucmakiola.calendula.domain.allowsEventMove
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resolvedZone import de.jeanlucmakiola.calendula.domain.resolvedZone
@@ -26,9 +29,11 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException 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. */ /** A new start instant — a timeline drag, which moves time and day at once. */
data class Start(val instant: Instant) : MoveTarget 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 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 * 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 — 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. * 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. */
val newAnchorDate: LocalDate,
) )
fun move(request: MoveRequest) { fun move(request: MoveRequest) {
if (_scopePrompt.value != null) return if (busy || _scopePrompt.value != null) return
busy = true
viewModelScope.launch { viewModelScope.launch {
val prepared = prepare(request) ?: return@launch val prepared = prepare(request)
if (prepared == null) {
busy = false
return@launch
}
if (!prepared.isRecurring) { if (!prepared.isRecurring) {
write(prepared, RecurringWriteScope.AllEvents) write(prepared, RecurringWriteScope.AllEvents)
busy = false
} else { } else {
// Still busy: the scope dialog is now the thing in flight.
pending = prepared pending = prepared
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign) _scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
} }
@@ -167,19 +195,28 @@ 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
// can't carry: writing it would leave anchor and rule disagreeing.
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
pending = null pending = null
_scopePrompt.value = null _scopePrompt.value = null
viewModelScope.launch { write(prepared, scope) } viewModelScope.launch {
write(prepared, scope)
busy = false
}
} }
/** Dismiss the scope dialog without writing. */ /** Dismiss the scope dialog without writing. */
fun cancelScope() { fun cancelScope() {
pending = null pending = null
busy = false
_scopePrompt.value = null _scopePrompt.value = null
} }
/** Put a completed move back where it came from. */ /** Put a completed move back where it came from. */
fun undo(undo: MoveUndo) { fun undo(undo: MoveUndo) {
if (busy) return
busy = true
_outcome.value = null _outcome.value = null
viewModelScope.launch { viewModelScope.launch {
_outcome.value = try { _outcome.value = try {
@@ -194,6 +231,7 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
MoveOutcome.Failed MoveOutcome.Failed
} }
busy = false
} }
} }
@@ -219,23 +257,16 @@ class RescheduleViewModel @Inject constructor(
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone) val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
val shifted = when (val target = request.target) { val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone) is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.Day -> original.shiftedByDays( is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
(
target.date.toEpochDays() -
sourceDate(request.beginMillis, original.isAllDay, zone).toEpochDays()
).toInt(),
)
} }
// A zero-distance drop is not a write. Matches the edit form's own // 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. // pristine-form no-op, and keeps a mis-aimed long press harmless.
if (shifted == original) return null if (shifted == original) return null
val problems = shifted.problems() // The UNTIL check is deferred to the write: how far the move reaches
if (EventFormProblem.RecurrenceEndsBeforeStart in problems) { // decides which date has to clear it, and "only this event" writes an
_outcome.value = MoveOutcome.BlockedSeriesEnd // exception row that no UNTIL constrains at all.
return null if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
}
if (problems.isNotEmpty()) {
_outcome.value = MoveOutcome.Failed _outcome.value = MoveOutcome.Failed
return null return null
} }
@@ -243,12 +274,23 @@ class RescheduleViewModel @Inject constructor(
// An exception row stands alone whatever rule a sync adapter left on it. // An exception row stands alone whatever rule a sync adapter left on it.
val isRecurring = original.rrule != null && !detail.isException val isRecurring = original.rrule != null && !detail.isException
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
// 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) { val realigned = if (isRecurring && movedDay) {
if (wholeDayShift) {
realignRecurrence( realignRecurrence(
requireNotNull(original.rrule), requireNotNull(original.rrule),
original.start.date, original.start.date,
shifted.start.date, shifted.start.date,
) )
} else {
null
}
} else { } else {
original.rrule original.rrule
} }
@@ -260,11 +302,20 @@ class RescheduleViewModel @Inject constructor(
updated = shifted.copy(rrule = realigned ?: original.rrule), updated = shifted.copy(rrule = realigned ?: original.rrule),
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
// 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) { private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
val request = prepared.request val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) {
_outcome.value = MoveOutcome.BlockedSeriesEnd
return
}
_outcome.value = try { _outcome.value = try {
when { when {
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents -> !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 * 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 (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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape 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.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -95,6 +96,13 @@ class TimelineGeometry {
var edgePx: Float = 0f var edgePx: Float = 0f
var stepPx: Float = 0f var stepPx: Float = 0f
var days: List<LocalDate> = emptyList() 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 grab = Offset.Zero
private var pointer = 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 val isDragging: Boolean get() = liftedInstanceId != null
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) { fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
@@ -130,7 +146,9 @@ class TimelineDragController {
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot grab = pointerInRoot - blockInRoot
pointer = pointerInRoot pointer = pointerInRoot
originSlot = null
recompute() recompute()
originSlot = drag?.slot
} }
fun move(pointerInRoot: Offset) { fun move(pointerInRoot: Offset) {
@@ -141,14 +159,20 @@ class TimelineDragController {
fun cancel() { fun cancel() {
source = null source = null
liftedInstanceId = null liftedInstanceId = null
originSlot = null
drag = 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? { fun finish(): TimelineDrop? {
val landed = drag val landed = drag
val origin = originSlot
cancel() 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. // 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.
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) 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) val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
drag = TimelineDrag( drag = TimelineDrag(
event = block.event, event = block.event,
date = days[column], date = days[dayIndex],
startMin = startMin, startMin = startMin,
endMin = startMin + span, endMin = startMin + span,
topLeftInRoot = Offset( topLeftInRoot = Offset(
@@ -270,6 +296,9 @@ 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
// it again would just duplicate the event for the drag's duration.
.clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() }, .onGloballyPositioned { origin = it.positionInRoot() },
) { ) {
val drag = controller.drag ?: return@Box 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) formatMinuteOfDay(drag.endMin.coerceAtMost(MINUTES_PER_DAY), use24Hour, locale)
Box( Box(
modifier = Modifier 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( IntOffset(
(drag.topLeftInRoot.x - origin.x).roundToInt(), (drag.topLeftInRoot.x - origin.x).roundToInt(),
(drag.topLeftInRoot.y - origin.y).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.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -536,6 +538,7 @@ private fun Timeline(
val locale = currentLocale() val locale = currentLocale()
val zoom = LocalTimelineZoom.current val zoom = LocalTimelineZoom.current
val density = LocalDensity.current val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // 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 // timeline's own viewport height, which is only known here — below the top
@@ -612,6 +615,7 @@ private fun Timeline(
it.columnGapPx = 0f it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat() it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date) it.days = listOf(state.date)
it.isRtl = isRtl
} }
}, },
) )
@@ -727,15 +731,16 @@ private fun EventBlock(
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 is
// midnight, not the event's start, so dragging it would invent a time. // midnight, not the event's start, so dragging it would invent a time.
val draggable = moveAction != null && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = moveAction != null && block.beginsOn(date, zone), enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
onMove = dragController::move, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = dragController.liftedInstanceId == block.event.instanceId val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
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.

View File

@@ -20,6 +20,12 @@ class MonthRowGeometry(
/** The row's day-column box — the space chip offsets are measured in. */ /** The row's day-column box — the space chip offsets are measured in. */
val cell: LayoutCoordinates, val cell: LayoutCoordinates,
val columnWidthPx: Float, 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. */ /** 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) val column = ((pointer.x - bounds.left) / row.columnWidthPx)
.toInt() .toInt()
.coerceIn(0, row.days.lastIndex) .coerceIn(0, row.days.lastIndex)
row.days[column] row.days[if (row.isRtl) row.days.lastIndex - column else column]
} }
} }
drag = MonthChipDrag( 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.clipToBounds
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius 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 androidx.compose.ui.geometry.Offset
import kotlin.math.roundToInt import kotlin.math.roundToInt
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
@@ -472,6 +476,9 @@ 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
// again would just duplicate the event for the drag's duration.
.clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() }, .onGloballyPositioned { origin = it.positionInRoot() },
) { ) {
val drag = controller.drag ?: return@Box val drag = controller.drag ?: return@Box
@@ -481,7 +488,9 @@ private fun MonthDragOverlay(controller: MonthDragController) {
continuesLeft = false, continuesLeft = false,
continuesRight = false, continuesRight = false,
modifier = Modifier 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( IntOffset(
(drag.topLeftInRoot.x - origin.x).roundToInt(), (drag.topLeftInRoot.x - origin.x).roundToInt(),
(drag.topLeftInRoot.y - origin.y).roundToInt(), (drag.topLeftInRoot.y - origin.y).roundToInt(),
@@ -1802,7 +1811,7 @@ private fun MonthWeekRow(
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) } val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val density = LocalDensity.current val density = LocalDensity.current
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() } val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
val zone = remember { TimeZone.currentSystemDefault() } val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val dragging = dragController?.isDragging == true val dragging = dragController?.isDragging == true
DisposableEffect(rowToken, dragController) { DisposableEffect(rowToken, dragController) {
onDispose { dragController?.removeRow(rowToken) } onDispose { dragController?.removeRow(rowToken) }
@@ -1831,6 +1840,7 @@ private fun MonthWeekRow(
days = week.days, days = week.days,
cell = coords, cell = coords,
columnWidthPx = coords.size.width / 7f, columnWidthPx = coords.size.width / 7f,
isRtl = isRtl,
), ),
) )
} }
@@ -1841,7 +1851,7 @@ private fun MonthWeekRow(
controller = dragController, controller = dragController,
band = bandCoordinates, band = bandCoordinates,
rowHeightPx = rowHeightPx, rowHeightPx = rowHeightPx,
zone = zone, isRtl = isRtl,
), ),
), ),
) { ) {
@@ -2083,7 +2093,7 @@ private fun monthChipDragModifier(
controller: MonthDragController?, controller: MonthDragController?,
band: Array<LayoutCoordinates?>, band: Array<LayoutCoordinates?>,
rowHeightPx: Float, rowHeightPx: Float,
zone: TimeZone, isRtl: Boolean,
): Modifier = rememberDragSurface( ): Modifier = rememberDragSurface(
enabled = moveScope != null && controller != null, enabled = moveScope != null && controller != null,
key = week.days.first(), key = week.days.first(),
@@ -2092,18 +2102,20 @@ private fun monthChipDragModifier(
val columnPx = size.width / 7f val columnPx = size.width / 7f
val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y) val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y)
val lane = (bandY / rowHeightPx).toInt() 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 column = (local.x / columnPx).toInt()
val dayIndex = if (isRtl) week.days.lastIndex - column else column
val event = if (bandY < 0f || columnPx <= 0f) { val event = if (bandY < 0f || columnPx <= 0f) {
null null
} else { } else {
week.chipAt(column, lane, MAX_EVENT_ROWS) week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
} }
if (event == null || moveScope?.allows(event) != true) { if (event == null || moveScope?.allows(event) != true) {
false false
} else { } else {
controller?.begin( controller?.begin(
event = event, event = event,
grabDate = week.days[column], grabDate = week.days[dayIndex],
pointerInRoot = pointerInRoot, pointerInRoot = pointerInRoot,
chipInRoot = Offset( chipInRoot = Offset(
x = nodeInRoot.x + column * columnPx, x = nodeInRoot.x + column * columnPx,
@@ -2117,19 +2129,17 @@ private fun monthChipDragModifier(
onMove = { controller?.move(it) }, onMove = { controller?.move(it) },
onDrop = { onDrop = {
controller?.finish()?.let { drop -> controller?.finish()?.let { drop ->
val delta = drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays() // How many columns the finger crossed — grabbing the middle of a
if (delta != 0L) { // 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( moveScope?.move(
MoveRequest( MoveRequest(
eventId = drop.event.eventId, eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(), endMillis = drop.event.end.toEpochMilliseconds(),
// The event's own first day plus the columns crossed — target = MoveTarget.ByDays(delta),
// 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),
),
), ),
) )
} }

View File

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

View File

@@ -109,7 +109,7 @@ class EventShiftTest {
assertThat(allDay.shiftedTo(target, berlin)).isEqualTo(allDay) 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.start.date).isEqualTo(LocalDate(2026, 6, 14))
assertThat(moved.end.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. // 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)), 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.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 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 @Test
fun `shifting by no days is a no-op`() { fun `shifting by no days is a no-op`() {
val original = form() 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 @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( assertThat(
realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday), realignRecurrence("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", monday, LocalDate(2026, 7, 20)),
).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=10") ).isNull()
} }
@Test @Test
fun `a yearly rule realigns both month and day`() { fun `BYDAY on a non-weekly rule is refused`() {
assertThat( // "every Monday of the month" is a shape this has not been reasoned about,
realignRecurrence( // and parseSimpleRecurrence can't read it either — so the UNTIL guard
"FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", // downstream would be blind to it.
monday, assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO", monday, wednesday)).isNull()
LocalDate(2026, 7, 20), }
),
).isEqualTo("FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=20") @Test
fun `a rule with no FREQ is refused`() {
assertThat(realignRecurrence("INTERVAL=2;BYDAY=MO", monday, wednesday)).isNull()
} }
@Test @Test
@@ -71,11 +78,28 @@ class RecurrenceRealignTest {
@Test @Test
fun `parts we cannot reason about are refused rather than guessed`() { 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() .isNull()
assertThat(realignRecurrence("FREQ=YEARLY;BYYEARDAY=159", 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 @Test
fun `a leading RRULE prefix is preserved`() { fun `a leading RRULE prefix is preserved`() {
assertThat(realignRecurrence("RRULE:FREQ=WEEKLY;BYDAY=MO", monday, wednesday)) assertThat(realignRecurrence("RRULE:FREQ=WEEKLY;BYDAY=MO", monday, wednesday))

View File

@@ -45,12 +45,14 @@ 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 UTC — midday so the device zone can't move the // Monday 2026-06-08, midday in whatever zone the test JVM runs in — resolved
// weekday out from under the BYDAY assertions. // 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 monday = LocalDate(2026, 6, 8)
private val wednesday = LocalDate(2026, 6, 10) private val beginMillis = LocalDateTime(monday, LocalTime(12, 0))
private val beginMillis = .toInstant(TimeZone.currentSystemDefault())
LocalDateTime(monday, LocalTime(12, 0)).toInstant(TimeZone.UTC).toEpochMilliseconds() .toEpochMilliseconds()
private val endMillis = beginMillis + 3_600_000L private val endMillis = beginMillis + 3_600_000L
private fun cal( private fun cal(
@@ -103,7 +105,7 @@ class RescheduleViewModelTest {
eventId = 42L, eventId = 42L,
beginMillis = beginMillis, beginMillis = beginMillis,
endMillis = endMillis, endMillis = endMillis,
target = MoveTarget.Day(wednesday), target = MoveTarget.ByDays(2),
) )
/** A drop an hour later, expressed the way a timeline drag expresses one. */ /** A drop an hour later, expressed the way a timeline drag expresses one. */
@@ -262,13 +264,62 @@ class RescheduleViewModelTest {
val vm = viewModel(tempDir, fake) val vm = viewModel(tempDir, fake)
vm.move(toWednesday()) vm.move(toWednesday())
vm.moveWithScope(RecurringWriteScope.AllEvents)
advanceUntilIdle() advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd) assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd)
assertThat(vm.scopePrompt.value).isNull()
assertThat(fake.updatedEvents).isEmpty() 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 @Test
fun `a zero-distance drop writes nothing and says nothing`( fun `a zero-distance drop writes nothing and says nothing`(
@TempDir tempDir: Path, @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 - **`RRULE` day parts go stale.** `buildEventUpdateValues` writes the rule
verbatim while `DTSTART` moves, so dragging a `FREQ=WEEKLY;BYDAY=MO` occurrence 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 onto a Wednesday under *All events* leaves a Wednesday anchor under a Monday
rule and the series does not move. `realignRecurrence` re-derives `BYDAY` / rule and the series does not move. `realignRecurrence` re-derives `BYDAY`, and
`BYMONTHDAY` / `BYMONTH` from the new date, and returns **null** for rules one returns **null** for everything else — the drop then offers only *this event*,
moved occurrence cannot resolve (`BYDAY=MO,WE`, `2TH`, `BYSETPOS`) — the drop whose exception row carries no rule at all. The same staleness is reachable
then offers only *this event*, whose exception row carries no rule at all. The from the edit screen; wiring it there too is a separate change.
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 - **The eligibility gate is load-bearing.** Nothing below the UI refuses a
write, so `CalendarSource.allowsEventMove` is what keeps read-only and write, so `CalendarSource.allowsEventMove` is what keeps read-only and
contact-managed events from being dragged. It is deliberately *not* contact-managed events from being dragged. It is deliberately *not*
`isEventTarget`: a managed event is editable (reminders, notes) yet must never `isEventTarget`: a managed event is editable (reminders, notes) yet must never
move, while a switched-off calendar renders nothing to grab anyway. 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 A drop that would push a series past its own `UNTIL` — the provider then
past its own `UNTIL` — which makes the provider generate zero occurrences and the generates zero occurrences and the event vanishes from every view — is refused
event vanish — is refused rather than written. 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 ### 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 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 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 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 ## Reminder delivery