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