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.
|
||||
|
||||
Reference in New Issue
Block a user