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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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(";")
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user