From aa24de443d54e00c7827d7f1906941ff758856b7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 1 Aug 2026 17:21:35 +0200 Subject: [PATCH] Drag events to reschedule them (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-press an event block in the week or day timeline and drag it to another time or day column; drag a chip in the month grid onto another date. The drop runs through the edit screen's own write path, so recurring writes, reminders and guests behave the same as a save. Two provider traps the drag had to close: - RRULE is written verbatim while DTSTART moves, so dragging a FREQ=WEEKLY;BYDAY=MO occurrence to a Wednesday under "all events" left a Wednesday anchor under a Monday rule and the series stayed put. realignRecurrence re-derives BYDAY/BYMONTHDAY/BYMONTH, and refuses rules one moved occurrence can't resolve (BYDAY=MO,WE, 2TH, BYSETPOS) — those may only move the single occurrence. - Nothing below the UI refuses a write, so allowsEventMove gates the gesture. Not isEventTarget: a managed contact-mirror event is editable but must never move. The pickup is hand-rolled rather than detectDragGesturesAfterLongPress: during the 500ms hold the block consumes nothing, so the scroll and page swipe claim at touch slop and kill the press, and the stock detector also cancels as soon as the finger leaves a block that may only be 10dp tall. Ours holds with a 6dp tolerance and never cancels on leaving bounds. Agenda is out of scope (list rows, no positioned blocks), as is resize-by-edge-drag. Month drags don't auto-scroll or page yet. --- .../data/calendar/EventDetailMapper.kt | 1 + .../calendula/data/calendar/Projections.kt | 6 + .../calendula/domain/CalendarRowState.kt | 10 + .../calendula/domain/EventShift.kt | 52 +++ .../jeanlucmakiola/calendula/domain/Models.kt | 7 + .../calendula/domain/Recurrence.kt | 2 +- .../calendula/domain/RecurrenceRealign.kt | 49 +++ .../calendula/ui/CalendarHost.kt | 33 ++ .../calendula/ui/common/EventDrag.kt | 165 ++++++++ .../calendula/ui/common/EventMoveHost.kt | 111 +++++ .../calendula/ui/common/EventMoveScope.kt | 57 +++ .../ui/common/RecurringScopeDialog.kt | 73 ++++ .../ui/common/RescheduleViewModel.kt | 341 +++++++++++++++ .../calendula/ui/common/TimelineDrag.kt | 323 +++++++++++++++ .../calendula/ui/day/DayScreen.kt | 136 ++++-- .../calendula/ui/edit/EventEditScreen.kt | 30 +- .../calendula/ui/month/MonthDrag.kt | 148 +++++++ .../calendula/ui/month/MonthScreen.kt | 196 ++++++++- .../calendula/ui/month/MonthUiState.kt | 20 + .../calendula/ui/week/WeekScreen.kt | 143 +++++-- app/src/main/res/values/strings.xml | 14 + .../data/calendar/EventWriteMapperTest.kt | 61 +++ .../calendula/domain/CalendarRowStateTest.kt | 21 + .../calendula/domain/EventShiftTest.kt | 137 ++++++ .../calendula/domain/RecurrenceRealignTest.kt | 90 ++++ .../ui/common/RescheduleViewModelTest.kt | 391 ++++++++++++++++++ docs/ARCHITECTURE.md | 44 ++ 27 files changed, 2579 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurringScopeDialog.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealignTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt index cbf572d..df22fa9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -125,6 +125,7 @@ internal fun ColumnReader.toEventDetailCore( selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)), eventColor = eventColor, eventColorKey = eventColorKey, + isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 21eb4e7..1b665aa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -89,6 +89,11 @@ internal object EventDetailProjection { // Recurring rows carry DURATION instead of DTEND; the detail screen // needs it to render a series opened without a named occurrence. CalendarContract.Events.DURATION, + // Non-null on a modified-occurrence exception row (it points at the + // series). "No RRULE" alone can't tell an exception from a master — a + // sync adapter that leaves the rule on the exception would otherwise + // get an exception written against an exception (#68). + CalendarContract.Events.ORIGINAL_ID, ) const val IDX_EVENT_ID = 0 @@ -110,6 +115,7 @@ internal object EventDetailProjection { const val IDX_SELF_ATTENDEE_STATUS = 16 const val IDX_EVENT_COLOR_KEY = 17 const val IDX_DURATION = 18 + const val IDX_ORIGINAL_ID = 19 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt index 30a80a7..72b91d0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/CalendarRowState.kt @@ -44,6 +44,16 @@ val CalendarSource.hasVisibilitySwitch: Boolean val CalendarSource.isEventTarget: Boolean get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced +/** + * Whether this calendar's events may have their times rewritten by a drag (#68). + * Deliberately not [isEventTarget]: an event living in a switched-off or + * non-syncing calendar isn't rendered anyway, while a *managed* event is + * editable (reminders, notes) yet must never move — the next contacts sync would + * put it back. + */ +val CalendarSource.allowsEventMove: Boolean + get() = canModifyContents && !isManaged + /** Every state worth naming on this calendar's row, in reading order. */ fun CalendarSource.stateLabels(): List = buildList { if (isManaged) add(CalendarStateLabel.MANAGED) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt new file mode 100644 index 0000000..db61856 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.calendula.domain + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +/** + * The zone this form's wall-clock times mean, matching what the data layer + * resolves them in at write time: the form's own pinned zone, else [deviceZone]. + * An unparseable pinned id falls back to the device, like the write path does. + */ +fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone = + timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone + +/** + * The form moved so it starts at [newStart], keeping its length. The wall-clock + * values are re-derived in the event's own zone, not the device's, so dragging a + * pinned event still means what the event means. + * + * The **instant** duration is preserved, not the wall-clock span: a recurring + * event's length travels to the provider as `DURATION`, so preserving wall clock + * would rewrite a whole series' length whenever a shift crosses a DST boundary. + * + * All-day events carry placeholder times and are date-anchored — move them with + * [shiftedByDays] instead; this returns them untouched. + */ +fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm { + if (isAllDay) return this + val zone = resolvedZone(deviceZone) + val span = end.toInstant(zone) - start.toInstant(zone) + return copy( + start = newStart.toLocalDateTime(zone), + end = (newStart + span).toLocalDateTime(zone), + ) +} + +/** + * 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. + */ +fun EventForm.shiftedByDays(days: Int): 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), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 76fb814..d3c41d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -124,6 +124,13 @@ data class EventDetail( val eventColor: Int? = null, /** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */ val eventColorKey: String? = null, + /** + * True when this row is a modified occurrence of a series (`ORIGINAL_ID` is + * set), not a master. Such a row stands alone — writing an exception against + * it would nest one exception inside another — so a reschedule always takes + * the plain whole-row path, whatever [rrule] a sync adapter left on it. + */ + val isException: Boolean = false, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt index 5090ac9..7b6ecde 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Recurrence.kt @@ -138,7 +138,7 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): } } -private val RRULE_DAY_CODES: Map = mapOf( +internal val RRULE_DAY_CODES: Map = mapOf( DayOfWeek.MONDAY to "MO", DayOfWeek.TUESDAY to "TU", DayOfWeek.WEDNESDAY to "WE", diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt new file mode 100644 index 0000000..5084c9e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt @@ -0,0 +1,49 @@ +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]. + * + * `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]. + * + * 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. + */ +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 + 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) { + "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 + else -> return null + } + } + return prefix + rebuilt.joinToString(";") +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 02f10a3..43f9c03 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,6 +28,10 @@ import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.EventMoveHost +import de.jeanlucmakiola.calendula.ui.common.EventMoveScope +import de.jeanlucmakiola.calendula.ui.common.LocalEventMove +import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.selectView @@ -294,6 +299,27 @@ fun CalendarHost( } } + // Drag to reschedule (#68). Hosted here so one instance serves every view and + // survives view switches; the calendar surfaces read it out of LocalEventMove. + val reschedule: RescheduleViewModel = hiltViewModel() + val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle() + val onEditEvent: (EventInstance) -> Unit = { event -> + val key = longArrayOf( + event.eventId, + event.start.toEpochMilliseconds(), + event.end.toEpochMilliseconds(), + ) + heldEditKey = key + editKey = key + } + val moveScope = remember(movableCalendarIds, reschedule) { + EventMoveScope( + movableCalendarIds = movableCalendarIds, + move = reschedule::move, + edit = onEditEvent, + ) + } + val slideSpec = rememberCalendarSlideSpec() // Base-level back: pop the view stack while no overlay covers it (each overlay @@ -311,6 +337,7 @@ fun CalendarHost( // navigation, so it fades through rather than sliding — paging *within* a // view keeps the directional slide. AnimatedContent keyed on the view type. val viewSwitch = fadeThrough() + CompositionLocalProvider(LocalEventMove provides moveScope) { AnimatedContent( targetState = view, transitionSpec = { viewSwitch }, @@ -367,6 +394,12 @@ fun CalendarHost( ) } } + } + + // Scope prompt + confirmation/undo snackbar for a dropped event. Declared + // right after the calendar views, so any overlay opened afterwards covers + // the snackbar rather than the other way round. + EventMoveHost(reschedule, modifier = Modifier.fillMaxSize()) // Search overlay — below detail/edit in the Box so a tapped result's // detail screen draws on top, and closing it returns to the results. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt new file mode 100644 index 0000000..ccb122e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt @@ -0,0 +1,165 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +/** + * How far the finger may wander during the hold. Deliberately well under touch + * slop (~18dp): past that an ancestor — the vertical scroll, the page swipe — + * claims the gesture, and a hold that survived to there would have opened a dead + * zone where neither the drag nor the page turn happens. + */ +private val PICKUP_TOLERANCE = 6.dp + +/** + * Pick an event block up with a long press and drag it, without + * `detectDragGesturesAfterLongPress`. + * + * The stock detector cancels the press as soon as an ancestor consumes — and + * during the hold the block itself consumes nothing, so the scroll and the page + * swipe are free to claim at their own slop — and it also cancels when the finger + * leaves the block, which a `MIN_EVENT_FRACTION`-tall block loses immediately. + * This one keeps its own timeout and its own (much smaller) tolerance, and never + * cancels on leaving the bounds. + * + * Movement before the timeout is deliberately **not** consumed: consuming it + * would kill the ancestor's gesture outright, so a scroll that happens to start + * on top of a block would die. Fast movement means the user meant to scroll — + * abandon quietly. A second finger means a pinch, which owns the gesture. + */ +@Composable +fun rememberEventDragSource( + enabled: Boolean, + key: Any?, + onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit, + onMove: (pointerInRoot: Offset) -> Unit, + onDrop: () -> Unit, + onCancel: () -> Unit, +): Modifier = rememberDragSurface( + enabled = enabled, + key = key, + onPickUp = { _, pointerInRoot, nodeInRoot, size -> + onPickUp(pointerInRoot, nodeInRoot, size) + true + }, + onMove = onMove, + onDrop = onDrop, + onCancel = onCancel, +) + +/** + * The same pickup, for a surface that carries many draggable pieces rather than + * being one itself — the month grid, whose chips are covered by a full-bleed tap + * layer and so can never take pointer input of their own. [onPickUp] receives the + * press position local to this node and answers whether anything is there; + * returning false abandons the gesture as if the hold had never completed. + */ +@Composable +fun rememberDragSurface( + enabled: Boolean, + key: Any?, + onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> Boolean, + onMove: (pointerInRoot: Offset) -> Unit, + onDrop: () -> Unit, + onCancel: () -> Unit, +): Modifier { + if (!enabled) return Modifier + val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis + val tolerance = with(LocalDensity.current) { PICKUP_TOLERANCE.toPx() } + val coordinates = remember { arrayOfNulls(1) } + val currentPickUp by rememberUpdatedState(onPickUp) + val currentMove by rememberUpdatedState(onMove) + val currentDrop by rememberUpdatedState(onDrop) + val currentCancel by rememberUpdatedState(onCancel) + + return Modifier + .onGloballyPositioned { coordinates[0] = it } + .pointerInput(key) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val origin = down.position + val heldStill = withTimeoutOrNull(holdMillis) { + while (true) { + val main = awaitPointerEvent() + val change = main.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + if (main.changes.count { it.pressed } > 1) break + if ((change.position - origin).getDistance() > tolerance) break + // An ancestor's claim only becomes visible once the whole + // main pass has run, so look again on the final pass. + val final = awaitPointerEvent(PointerEventPass.Final) + if (final.changes.any { it.isConsumed }) break + } + } == null + if (!heldStill) return@awaitEachGesture + + val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture + val took = currentPickUp( + down.position, + layout.localToRoot(down.position), + layout.positionInRoot(), + layout.size, + ) + if (!took) return@awaitEachGesture + var dropped = false + try { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + 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 } + ?.let { currentMove(it.localToRoot(change.position)) } + } + } finally { + // Also reached when the pointer node is disposed mid-drag (the + // page swapping out under the finger) — that is a cancel, not a + // drop, and must never write. + if (dropped) currentDrop() else currentCancel() + } + } + } +} + +/** + * The app's first haptics: a lift on pickup, then a tick every time the drop + * target snaps to a different slot, so the granularity is felt rather than read. + */ +@Composable +fun DragSnapHaptics(slot: Any?) { + val haptics = LocalHapticFeedback.current + val previous = remember { arrayOfNulls(1) } + LaunchedEffect(slot) { + val had = previous[0] + previous[0] = slot + when { + slot == null -> Unit + had == null -> haptics.performHapticFeedback(HapticFeedbackType.LongPress) + had != slot -> haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt new file mode 100644 index 0000000..d291527 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -0,0 +1,111 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.floret.locale.currentLocale +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.Locale + +/** + * The two surfaces a drag-and-drop reschedule needs on top of the calendar: the + * recurring-scope prompt, and the confirmation snackbar carrying Undo. + * + * None of the four calendar screens sets a `snackbarHost` on its Scaffold, so + * this hosts its own — bottom-centre inside the host's root Box, above every + * view. It forgoes the Scaffold's FAB avoidance in exchange for not threading a + * `SnackbarHostState` through all four screens. + */ +@Composable +fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) { + val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle() + val outcome by viewModel.outcome.collectAsStateWithLifecycle() + val snackbars = remember { SnackbarHostState() } + val locale = currentLocale() + val use24Hour = LocalUse24HourFormat.current + + prompt?.let { pending -> + RecurringScopeDialog( + title = stringResource(R.string.event_move_recurring_title), + onSelect = viewModel::moveWithScope, + onDismiss = viewModel::cancelScope, + allowSeries = !pending.occurrenceOnly, + reason = stringResource(R.string.event_move_occurrence_only) + .takeIf { pending.occurrenceOnly }, + ) + } + + val moved = outcome as? MoveOutcome.Moved + val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) } + val message = when (val o = outcome) { + null -> null + is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty()) + MoveOutcome.Undone -> stringResource(R.string.event_move_undone) + MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied) + MoveOutcome.Gone -> stringResource(R.string.event_move_gone) + MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end) + MoveOutcome.Failed -> stringResource(R.string.event_move_failed) + } + val undoLabel = stringResource(R.string.event_move_undo) + + LaunchedEffect(outcome) { + val text = message ?: return@LaunchedEffect + val undo = moved?.undo + val result = snackbars.showSnackbar( + message = text, + actionLabel = undoLabel.takeIf { undo != null }, + withDismissAction = undo == null, + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed && undo != null) { + viewModel.undo(undo) + } else { + viewModel.consumeOutcome() + } + } + + Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter) { + SnackbarHost( + hostState = snackbars, + modifier = Modifier.navigationBarsPadding().padding(16.dp), + ) + } +} + +/** + * "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events + * are read back on the UTC calendar day they are anchored to (#65, #82). + */ +private fun formatMovedTo( + startMillis: Long, + isAllDay: Boolean, + use24Hour: Boolean, + locale: Locale, +): String { + val zone: ZoneId = if (isAllDay) ZoneOffset.UTC else ZoneId.systemDefault() + val skeleton = when { + isAllDay -> "EEEdMMM" + use24Hour -> "EEEdMMMHm" + else -> "EEEdMMMhm" + } + return localizedDateFormatter(locale, skeleton) + .format(Instant.ofEpochMilli(startMillis).atZone(zone)) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt new file mode 100644 index 0000000..3b1caf3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance + +/** + * Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by + * whichever event block is being composed. A composition local rather than six + * layers of parameters: every calendar surface needs the same three things, and + * the blocks that need them sit deep inside private composables. + * + * Null means moving is off entirely (no host provided it) — a block then + * registers no drag gesture at all, so a long press keeps its old meaning of + * nothing happening. + */ +@Immutable +class EventMoveScope( + /** + * Calendars whose events may be moved. Nothing below the UI enforces this — + * the repository writes whatever it is handed — so this gate is load-bearing. + */ + val movableCalendarIds: Set, + val move: (MoveRequest) -> Unit, + /** Open an event in the edit form — the pointer-free route to the same change. */ + val edit: (EventInstance) -> Unit, +) { + fun allows(event: EventInstance): Boolean = event.calendarId in movableCalendarIds +} + +val LocalEventMove = compositionLocalOf { null } + +/** Opacity the source block keeps while its floating copy travels. */ +const val GHOST_ALPHA: Float = 0.3f + +/** + * A TalkBack action that opens [event] in the edit form, so rescheduling isn't + * pointer-only. Null when this event can't be moved — the block then carries no + * action and registers no drag either. + */ +@Composable +fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? { + val move = LocalEventMove.current ?: return null + if (!move.allows(event)) return null + val label = stringResource(R.string.event_move_action) + return remember(event.instanceId, label, move) { + CustomAccessibilityAction(label) { + move.edit(event) + true + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurringScopeDialog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurringScopeDialog.kt new file mode 100644 index 0000000..2765c67 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurringScopeDialog.kt @@ -0,0 +1,73 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.floret.components.OptionCard + +/** + * How far a write to a recurring event should reach: this occurrence, it and + * everything after (a series split), or the whole series. + * + * One of the two carve-outs from the full-screen picker rule — a two-or-three + * option decision taken mid-action reads better as a popup than as a near-empty + * screen. Shared by the edit screen's save and a drag-and-drop reschedule, which + * want the same three options for the same reasons. + * + * [allowOccurrence] drops "only this event" (an exception row can't carry its own + * rule, so a changed recurrence rules it out); [allowSeries] drops the two wider + * options, for a rule whose days can't be recalculated from one moved + * occurrence — [reason] then says why. + */ +@Composable +fun RecurringScopeDialog( + title: String, + onSelect: (RecurringWriteScope) -> Unit, + onDismiss: () -> Unit, + allowOccurrence: Boolean = true, + allowSeries: Boolean = true, + reason: String? = null, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (reason != null) { + Text( + text = reason, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (allowOccurrence) { + OptionCard( + label = stringResource(R.string.event_delete_option_occurrence), + onClick = { onSelect(RecurringWriteScope.ThisEvent) }, + ) + } + if (allowSeries) { + OptionCard( + label = stringResource(R.string.event_delete_option_following), + onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) }, + ) + OptionCard( + label = stringResource(R.string.event_delete_option_series), + onClick = { onSelect(RecurringWriteScope.AllEvents) }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt new file mode 100644 index 0000000..cf6cf68 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -0,0 +1,341 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.EventForm +import de.jeanlucmakiola.calendula.domain.EventFormProblem +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.allowsEventMove +import de.jeanlucmakiola.calendula.domain.problems +import de.jeanlucmakiola.calendula.domain.realignRecurrence +import de.jeanlucmakiola.calendula.domain.resolvedZone +import de.jeanlucmakiola.calendula.domain.shiftedByDays +import de.jeanlucmakiola.calendula.domain.shiftedTo +import de.jeanlucmakiola.calendula.domain.toEditForm +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Instant +import javax.inject.Inject + +/** Where a dragged event should land. */ +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 +} + +/** + * One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's* + * own times (`Instances.BEGIN`/`END`), exactly as the detail and edit screens + * pass them, so a recurring series resolves the right occurrence. + */ +data class MoveRequest( + val eventId: Long, + val beginMillis: Long, + val endMillis: Long, + val target: MoveTarget, +) + +/** + * A recurring drop waiting for the user to pick how far it reaches. + * [occurrenceOnly] means the rule names days that a single moved occurrence + * can't re-derive (`BYDAY=MO,WE`, `2TH`, …), so the only honest option left is + * this one occurrence — see [realignRecurrence]. + */ +data class MoveScopePrompt(val occurrenceOnly: Boolean) + +/** The inverse of a completed move, for the undo action. */ +data class MoveUndo( + val eventId: Long, + /** The form as it now stands (the moved state). */ + val moved: EventForm, + /** The form as it stood before the move. */ + val restored: EventForm, +) + +sealed interface MoveOutcome { + /** + * Written. [startMillis] is where the dragged occurrence now begins, for the + * confirmation message; [undo] is null when the write has no clean inverse. + */ + data class Moved( + val startMillis: Long, + val isAllDay: Boolean, + val undo: MoveUndo?, + ) : MoveOutcome + + data object Undone : MoveOutcome + + /** `WRITE_CALENDAR` was revoked between the drop and the provider call. */ + data object WriteDenied : MoveOutcome + + /** The event vanished (sync, another device) between the read and the write. */ + data object Gone : MoveOutcome + + /** The drop would push the series past its own `UNTIL`, generating nothing. */ + data object BlockedSeriesEnd : MoveOutcome + + data object Failed : MoveOutcome +} + +/** + * Writes a drag-and-drop reschedule (#68) through the same repository calls the + * edit screen's save uses, so recurring writes, reminder reconciliation and + * attendee preservation behave identically. Hosted at `CalendarHost` so one + * instance serves every calendar view and survives view switches. + * + * The full prefilled form is carried through the write — never a stripped one — + * because the occurrence-exception path reconciles reminders and attendees onto + * the new row, and a partial form would wipe them. + */ +@HiltViewModel +class RescheduleViewModel @Inject constructor( + private val repository: CalendarRepository, + @IoDispatcher private val io: CoroutineDispatcher, +) : ViewModel() { + + private val _scopePrompt = MutableStateFlow(null) + val scopePrompt: StateFlow = _scopePrompt.asStateFlow() + + private val _outcome = MutableStateFlow(null) + val outcome: StateFlow = _outcome.asStateFlow() + + private var pending: PreparedMove? = null + + /** + * The calendars whose events may be dragged. Nothing below the UI guards + * this — the repository and data source attempt any write handed to them — + * so a block outside this set registers no drag gesture at all. + */ + val movableCalendarIds: StateFlow> = repository.calendars() + .map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() } + .catch { emit(emptySet()) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = emptySet(), + ) + + /** Everything a drop needs, resolved before anything is written. */ + private data class PreparedMove( + val request: MoveRequest, + val original: EventForm, + val updated: EventForm, + /** True for a series master with a rule — an exception row is not one. */ + val isRecurring: Boolean, + /** + * False when the rule names days this move can't re-derive, so anything + * wider than the single occurrence would leave rule and anchor disagreeing. + */ + val canRealign: Boolean, + ) + + fun move(request: MoveRequest) { + if (_scopePrompt.value != null) return + viewModelScope.launch { + val prepared = prepare(request) ?: return@launch + if (!prepared.isRecurring) { + write(prepared, RecurringWriteScope.AllEvents) + } else { + pending = prepared + _scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign) + } + } + } + + /** Answer the scope dialog. */ + fun moveWithScope(scope: RecurringWriteScope) { + val prepared = pending ?: return + pending = null + _scopePrompt.value = null + viewModelScope.launch { write(prepared, scope) } + } + + /** Dismiss the scope dialog without writing. */ + fun cancelScope() { + pending = null + _scopePrompt.value = null + } + + /** Put a completed move back where it came from. */ + fun undo(undo: MoveUndo) { + _outcome.value = null + viewModelScope.launch { + _outcome.value = try { + repository.updateEvent(undo.eventId, undo.moved, undo.restored) + MoveOutcome.Undone + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + MoveOutcome.WriteDenied + } catch (e: NoSuchEventException) { + MoveOutcome.Gone + } catch (e: Exception) { + MoveOutcome.Failed + } + } + } + + /** Clear the outcome once the screen has shown it. */ + fun consumeOutcome() { + _outcome.value = null + } + + private suspend fun prepare(request: MoveRequest): PreparedMove? { + val detail = try { + repository.eventDetail(request.eventId) + } catch (e: CancellationException) { + throw e + } catch (e: NoSuchEventException) { + _outcome.value = MoveOutcome.Gone + return null + } catch (e: Exception) { + _outcome.value = MoveOutcome.Failed + return null + } + + val zone = TimeZone.currentSystemDefault() + 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(), + ) + } + // 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()) { + _outcome.value = MoveOutcome.Failed + return null + } + + // 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 + val realigned = if (isRecurring && movedDay) { + realignRecurrence( + requireNotNull(original.rrule), + original.start.date, + shifted.start.date, + ) + } else { + original.rrule + } + return PreparedMove( + request = request, + original = original, + // A rule we can't re-derive stays verbatim: the only scope offered + // then is the single occurrence, whose exception row carries no rule. + updated = shifted.copy(rrule = realigned ?: original.rrule), + isRecurring = isRecurring, + canRealign = !isRecurring || !movedDay || realigned != null, + ) + } + + private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) { + val request = prepared.request + _outcome.value = try { + when { + !prepared.isRecurring || scope == RecurringWriteScope.AllEvents -> + repository.updateEvent(request.eventId, prepared.original, prepared.updated) + + scope == RecurringWriteScope.ThisEvent -> + repository.updateOccurrence( + request.eventId, + request.beginMillis, + prepared.updated, + ) + + else -> repository.updateEventFromOccurrence( + eventId = request.eventId, + beginMillis = request.beginMillis, + original = prepared.original, + updated = prepared.updated, + ) + } + MoveOutcome.Moved( + startMillis = movedStartMillis(prepared), + isAllDay = prepared.updated.isAllDay, + undo = undoFor(prepared, scope), + ) + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + MoveOutcome.WriteDenied + } catch (e: NoSuchEventException) { + MoveOutcome.Gone + } catch (e: Exception) { + MoveOutcome.Failed + } + } + + /** + * Undo is offered only where the inverse is one symmetric write: a + * non-recurring event (absolute DTSTART/DTEND) and a whole-series move (the + * −Δ wall-clock shift lands on the re-read anchor). "This event" leaves an + * exception row behind and "this and following" splits the series with an + * UNTIL truncation — neither is undone by shifting back. + */ + private fun undoFor(prepared: PreparedMove, scope: RecurringWriteScope): MoveUndo? = + if (!prepared.isRecurring || scope == RecurringWriteScope.AllEvents) { + MoveUndo( + eventId = prepared.request.eventId, + moved = prepared.updated, + restored = prepared.original, + ) + } else { + null + } + + /** + * Where the dragged occurrence now begins, in the same anchoring the views + * read dates back in: a UTC midnight for an all-day event, the real instant + * for a timed one. + */ + private fun movedStartMillis(prepared: PreparedMove): Long { + val form = prepared.updated + return if (form.isAllDay) { + form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + } else { + form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault())) + .toEpochMilliseconds() + } + } + + /** 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt new file mode 100644 index 0000000..99595d1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -0,0 +1,323 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +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.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY +import de.jeanlucmakiola.calendula.ui.week.TimedBlock +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import de.jeanlucmakiola.floret.locale.currentLocale +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.math.roundToInt +import kotlin.time.Instant + +/** Start granularity a dragged block snaps to, matching the big calendar apps. */ +const val DRAG_SNAP_MINUTES: Int = 15 + +/** How close to a timeline edge the finger must get before the view scrolls. */ +private val AUTO_SCROLL_EDGE = 64.dp + +/** Fastest auto-scroll step, per frame, right at the edge. */ +private val AUTO_SCROLL_STEP = 16.dp + +/** A block being dragged, in root coordinates so it can be drawn in an overlay. */ +data class TimelineDrag( + val event: EventInstance, + val date: LocalDate, + val startMin: Int, + val endMin: Int, + val topLeftInRoot: Offset, + val sizePx: IntSize, +) { + /** What the snap haptics key off: one tick per changed slot, not per frame. */ + val slot: Pair get() = date to startMin +} + +/** Where a finished drag asks its event to go. */ +data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int) + +/** + * The timeline's live geometry, republished on every layout. Held in plain + * fields rather than snapshot state on purpose: it changes on every scroll + * frame, and recomposing the screen that often would cost far more than the + * drag loop's own per-frame read of it. + */ +class TimelineGeometry { + /** The day-columns row — scrolling *content*, so its root position folds in the scroll. */ + var grid: LayoutCoordinates? = null + + /** The scroll viewport, for the edge that triggers auto-scrolling. */ + var viewport: LayoutCoordinates? = null + var scroll: ScrollState? = null + var hourPx: Float = 0f + + /** Column pitch — one column plus the gap after it. */ + var columnWidthPx: Float = 0f + var columnGapPx: Float = 0f + var edgePx: Float = 0f + var stepPx: Float = 0f + var days: List = emptyList() +} + +/** + * Hoisted drag state for one timeline (#68). It lives above the per-page + * `AnimatedContent` — a page change mid-drag would otherwise strand a ghost — + * and the block it renders is drawn in an overlay: a `Card` column ends its + * modifier chain with a clip, so a block offset toward the neighbouring column + * would simply be cut off in place. + */ +@Stable +class TimelineDragController { + val geometry = TimelineGeometry() + + var drag: TimelineDrag? by mutableStateOf(null) + private set + + /** + * Which block is lifted, and whether anything is. Separate snapshot state + * from [drag] on purpose: [drag] changes on every frame, and the blocks that + * only need to know "am I the ghost" must not recompose that often. + */ + var liftedInstanceId: Long? by mutableStateOf(null) + private set + + private var source: TimedBlock? = null + private var grab = Offset.Zero + private var pointer = Offset.Zero + + val isDragging: Boolean get() = liftedInstanceId != null + + fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) { + source = block + liftedInstanceId = block.event.instanceId + grab = pointerInRoot - blockInRoot + pointer = pointerInRoot + recompute() + } + + fun move(pointerInRoot: Offset) { + pointer = pointerInRoot + recompute() + } + + fun cancel() { + source = null + liftedInstanceId = null + drag = null + } + + /** End the drag, handing back where it landed (null if it never resolved). */ + fun finish(): TimelineDrop? { + val landed = drag + cancel() + return landed?.let { TimelineDrop(it.event, it.date, it.startMin) } + } + + /** + * Re-derive the target from the pointer's *root* position. Recomputed rather + * than accumulated from `positionChange()`, because a stationary finger emits + * no move events while auto-scroll walks the content underneath it. + */ + fun recompute() { + val block = source ?: return + val grid = geometry.grid?.takeIf { it.isAttached } ?: return + val hourPx = geometry.hourPx + val columnPx = geometry.columnWidthPx + val days = geometry.days + if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return + + val origin = grid.positionInRoot() + val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f + val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES + // Clamp the start into the target day; a tail running past midnight is + // 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 + val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) + val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx) + drag = TimelineDrag( + event = block.event, + date = days[column], + startMin = startMin, + endMin = startMin + span, + topLeftInRoot = Offset( + x = origin.x + column * columnPx, + y = origin.y + startMin / 60f * hourPx, + ), + sizePx = IntSize( + (columnPx - geometry.columnGapPx).roundToInt(), + height.roundToInt(), + ), + ) + } + + /** + * Hold the scroll for the whole drag and nudge it once per frame while the + * finger sits near an edge. One `scroll` call, not a loop of + * `animateScrollBy`: each of those re-acquires the mutex and cancels the last. + */ + suspend fun autoScroll() { + val scroll = geometry.scroll ?: return + scroll.scroll(MutatePriority.UserInput) { + while (true) { + withFrameNanos { } + recompute() + val step = edgeStep() + if (step != 0f) scrollBy(step) + } + } + } + + private fun edgeStep(): Float { + val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f + val edge = geometry.edgePx + if (edge <= 0f) return 0f + val bounds = viewport.boundsInRoot() + val fromTop = pointer.y - bounds.top + val fromBottom = bounds.bottom - pointer.y + return when { + fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx + fromBottom < edge -> (edge - fromBottom) / edge * geometry.stepPx + else -> 0f + } + } +} + +@Composable +fun rememberTimelineDragController(): TimelineDragController { + val controller = remember { TimelineDragController() } + val density = LocalDensity.current + controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() } + controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() } + return controller +} + +/** + * True when [block] actually begins on [day] rather than being the tail of an + * event that started earlier. A clipped block's top edge is midnight, not the + * event's start, so dragging it would move the event to a time it never had. + */ +fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean = + event.start.toLocalDateTime(zone).date == day + +/** + * The instant a drop asks for, read in the zone the timeline is drawn in. A drop + * into a spring-forward gap has no instant of its own; `toInstant` resolves it + * forward by the missing hour, and the block visibly settles there. + */ +fun TimelineDrop.startInstant(zone: TimeZone): Instant = + LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone) + +/** + * The floating block, drawn over the whole calendar so it is free of the day + * column's clip and of the scroll viewport's rounded corners. + */ +@Composable +fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) { + var origin by remember { mutableStateOf(Offset.Zero) } + val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current + val use24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + val reduceMotion = rememberReduceMotion() + val density = LocalDensity.current + + // Both live here rather than beside the controller: they read [drag], which + // changes every frame, and this composable is the one that is meant to. + DragSnapHaptics(controller.drag?.slot) + LaunchedEffect(controller.isDragging) { + if (controller.isDragging) controller.autoScroll() + } + + Box( + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { origin = it.positionInRoot() }, + ) { + val drag = controller.drag ?: return@Box + val fill = eventFill(drag.event.color, dark, soften) + val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } + val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" + + formatMinuteOfDay(drag.endMin.coerceAtMost(MINUTES_PER_DAY), use24Hour, locale) + Box( + modifier = Modifier + .offset { + IntOffset( + (drag.topLeftInRoot.x - origin.x).roundToInt(), + (drag.topLeftInRoot.y - origin.y).roundToInt(), + ) + } + .size( + width = with(density) { drag.sizePx.width.toDp() }, + height = with(density) { drag.sizePx.height.toDp() }, + ) + .padding(horizontal = 1.dp) + .graphicsLayer { + if (!reduceMotion) { + scaleX = 1.02f + scaleY = 1.02f + } + shadowElevation = 8.dp.toPx() + shape = RoundedCornerShape(4.dp) + clip = false + } + .background(fill, RoundedCornerShape(4.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Column { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = eventInk(fill, alpha = 0.85f), + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), + ) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 1027d13..bbaa4d8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -53,11 +53,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.draw.alpha 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.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 @@ -74,7 +77,19 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA +import de.jeanlucmakiola.calendula.ui.common.LocalEventMove +import de.jeanlucmakiola.calendula.ui.common.MoveRequest +import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.NowLine +import de.jeanlucmakiola.calendula.ui.common.TimelineDragController +import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay +import de.jeanlucmakiola.calendula.ui.common.TimelineDrop +import de.jeanlucmakiola.calendula.ui.common.beginsOn +import de.jeanlucmakiola.calendula.ui.common.eventMoveAction +import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource +import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController +import de.jeanlucmakiola.calendula.ui.common.startInstant import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec @@ -298,31 +313,51 @@ private fun DayContent( // drag is consumed by the inner scroll first — the two gestures coexist. val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) - AnimatedContent( - targetState = state, - modifier = modifier.then(swipeModifier), - contentKey = { s -> + // Above the AnimatedContent: a page change mid-drag would strand the + // floating block inside the outgoing page. + val dragController = rememberTimelineDragController() + val move = LocalEventMove.current + val zone = remember { TimeZone.currentSystemDefault() } + + Box(modifier = modifier) { + AnimatedContent( + targetState = state, + modifier = Modifier.fillMaxSize().then(swipeModifier), + contentKey = { s -> + when (s) { + is DayUiState.Success -> "success-${s.date}" + is DayUiState.Failure -> "failure-${s.reason}" + DayUiState.Loading -> "loading" + } + }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, + label = "day-transition", + ) { s -> when (s) { - is DayUiState.Success -> "success-${s.date}" - is DayUiState.Failure -> "failure-${s.reason}" - DayUiState.Loading -> "loading" + DayUiState.Loading -> DayLoading() + is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) + is DayUiState.Success -> DaySuccess( + state = s, + topSectionColor = topSectionColor, + scrollState = scrollState, + allDayHeight = allDayHeight, + dragController = dragController, + onEventClick = onEventClick, + onCreateAt = onCreateAt, + onDrop = { drop -> + move?.move( + MoveRequest( + eventId = drop.event.eventId, + beginMillis = drop.event.start.toEpochMilliseconds(), + endMillis = drop.event.end.toEpochMilliseconds(), + target = MoveTarget.Start(drop.startInstant(zone)), + ), + ) + }, + ) } - }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, - label = "day-transition", - ) { s -> - when (s) { - DayUiState.Loading -> DayLoading() - is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is DayUiState.Success -> DaySuccess( - state = s, - topSectionColor = topSectionColor, - scrollState = scrollState, - allDayHeight = allDayHeight, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - ) } + TimelineDragOverlay(dragController) } } @@ -332,8 +367,10 @@ private fun DaySuccess( topSectionColor: Color, scrollState: ScrollState, allDayHeight: Dp, + dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { // All-day strip collapses to nothing when the day has no all-day events, @@ -352,8 +389,10 @@ private fun DaySuccess( Timeline( state = state, scrollState = scrollState, + dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, + onDrop = onDrop, ) } } @@ -487,13 +526,16 @@ private fun AllDayBar( private fun Timeline( state: DayUiState.Success, scrollState: ScrollState, + dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, ) { val dark = isSystemInDarkTheme() val use24Hour = LocalUse24HourFormat.current val locale = currentLocale() val zoom = LocalTimelineZoom.current + val density = LocalDensity.current // 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 @@ -544,7 +586,8 @@ private fun Timeline( .weight(1f) .fillMaxHeight() .clip(RoundedCornerShape(16.dp)) - .verticalScroll(scrollState), + .verticalScroll(scrollState) + .onGloballyPositioned { dragController.geometry.viewport = it }, ) { DayColumnCard( blocks = state.timed, @@ -552,11 +595,25 @@ private fun Timeline( date = state.date, today = state.today, hourHeight = hourHeight, + dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, + onDrop = onDrop, modifier = Modifier .fillMaxWidth() - .height(totalHeight), + .height(totalHeight) + // The scrolling content itself, so its root position + // already folds in the scroll offset. + .onGloballyPositioned { coords -> + dragController.geometry.let { + it.grid = coords + it.scroll = scrollState + it.hourPx = with(density) { hourHeight.toPx() } + it.columnGapPx = 0f + it.columnWidthPx = coords.size.width.toFloat() + it.days = listOf(state.date) + } + }, ) } } @@ -570,8 +627,10 @@ private fun DayColumnCard( date: LocalDate, today: LocalDate, hourHeight: Dp, + dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, modifier: Modifier = Modifier, ) { val hourPx = with(LocalDensity.current) { hourHeight.toPx() } @@ -613,7 +672,10 @@ private fun DayColumnCard( block = block, dark = dark, height = height, + date = date, + dragController = dragController, onClick = { onEventClick(block.event) }, + onDrop = onDrop, modifier = Modifier .offset(x = laneWidth * block.lane, y = top) .width(laneWidth) @@ -634,7 +696,10 @@ private fun EventBlock( block: TimedBlock, dark: Boolean, height: Dp, + date: LocalDate, + dragController: TimelineDragController, onClick: () -> Unit, + onDrop: (TimelineDrop) -> Unit, modifier: Modifier = Modifier, ) { val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } @@ -658,12 +723,33 @@ private fun EventBlock( val showTitle = available >= titleLineHeight val soften = LocalSoftenColors.current val fill = eventFill(block.event.color, dark, soften) + val zone = remember { TimeZone.currentSystemDefault() } + 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 dragModifier = rememberEventDragSource( + enabled = moveAction != null && block.beginsOn(date, zone), + 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 Box( modifier = modifier + // The source stays put as a ghost while its floating copy travels. + .then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier) .background(fill, RoundedCornerShape(4.dp)) .clickable(onClick = onClick) + // After clickable, so it is the inner node and wins the main pass; + // the tap still works, since a drag consumes the up. + .then(dragModifier) .padding(horizontal = 4.dp, vertical = 2.dp) - .semantics { contentDescription = "$title, $timeLabel" }, + .semantics { + contentDescription = "$title, $timeLabel" + if (moveAction != null) customActions = listOf(moveAction) + }, ) { Column { if (showTitle) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 76b5cef..4f36992 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -127,6 +127,7 @@ import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette +import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow @@ -452,30 +453,11 @@ private fun SaveScopeDialog( onSelect: (RecurringWriteScope) -> Unit, onDismiss: () -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.event_edit_recurring_title)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (!recurrenceChanged) { - OptionCard( - label = stringResource(R.string.event_delete_option_occurrence), - onClick = { onSelect(RecurringWriteScope.ThisEvent) }, - ) - } - OptionCard( - label = stringResource(R.string.event_delete_option_following), - onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) }, - ) - OptionCard( - label = stringResource(R.string.event_delete_option_series), - onClick = { onSelect(RecurringWriteScope.AllEvents) }, - ) - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } - }, + RecurringScopeDialog( + title = stringResource(R.string.event_edit_recurring_title), + onSelect = onSelect, + onDismiss = onDismiss, + allowOccurrence = !recurrenceChanged, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt new file mode 100644 index 0000000..408fa05 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt @@ -0,0 +1,148 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.unit.IntSize +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate + +/** One week row's live geometry, republished on every layout while it is on screen. */ +class MonthRowGeometry( + val days: List, + /** The row's day-column box — the space chip offsets are measured in. */ + val cell: LayoutCoordinates, + val columnWidthPx: Float, +) + +/** A chip in flight, in root coordinates so it can be drawn in an overlay. */ +data class MonthChipDrag( + val event: EventInstance, + /** The day whose column the chip was grabbed in — the drag is a delta from it. */ + val grabDate: LocalDate, + val targetDate: LocalDate?, + val topLeftInRoot: Offset, + val sizePx: IntSize, +) + +/** Where a finished chip drag asks its event to go, as a whole-day shift. */ +data class MonthChipDrop( + val event: EventInstance, + val grabDate: LocalDate, + val targetDate: LocalDate, +) + +/** + * Hoisted drag state for the month grid (#68). + * + * Every visible week row registers itself, so a chip can be carried across rows: + * the pointer node that took the press keeps receiving events after the finger + * has left its own bounds, and the target is then resolved against whichever + * registered row the finger is actually over. Rows are keyed by an identity token + * rather than by their date, because the continuous style can show the same week + * twice — once in each adjoining month. + */ +@Stable +class MonthDragController { + private val rows = LinkedHashMap() + + var drag: MonthChipDrag? by mutableStateOf(null) + private set + + /** + * Which chip is lifted, and whether anything is. Separate snapshot state from + * [drag] on purpose: [drag] changes on every frame, while the chips and rows + * that only need "am I the ghost" / "must I stop clipping" must not. + */ + var liftedInstanceId: Long? by mutableStateOf(null) + private set + + private var event: EventInstance? = null + private var grabDate: LocalDate? = null + private var grab = Offset.Zero + private var pointer = Offset.Zero + private var sizePx = IntSize.Zero + + val isDragging: Boolean get() = liftedInstanceId != null + + fun putRow(token: Any, geometry: MonthRowGeometry) { + rows[token] = geometry + } + + fun removeRow(token: Any) { + rows.remove(token) + } + + fun begin( + event: EventInstance, + grabDate: LocalDate, + pointerInRoot: Offset, + chipInRoot: Offset, + size: IntSize, + ) { + this.event = event + this.grabDate = grabDate + liftedInstanceId = event.instanceId + grab = pointerInRoot - chipInRoot + pointer = pointerInRoot + sizePx = size + recompute() + } + + fun move(pointerInRoot: Offset) { + pointer = pointerInRoot + recompute() + } + + fun cancel() { + event = null + grabDate = null + liftedInstanceId = null + drag = null + } + + fun finish(): MonthChipDrop? { + val landed = drag + cancel() + val target = landed?.targetDate ?: return null + return MonthChipDrop(landed.event, landed.grabDate, target) + } + + private fun recompute() { + val event = event ?: return + val grabbed = grabDate ?: return + val resolved = rows.values.firstNotNullOfOrNull { row -> + val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot() + ?: return@firstNotNullOfOrNull null + if (!bounds.contains(pointer) || row.columnWidthPx <= 0f) { + null + } else { + val column = ((pointer.x - bounds.left) / row.columnWidthPx) + .toInt() + .coerceIn(0, row.days.lastIndex) + row.days[column] + } + } + drag = MonthChipDrag( + event = event, + grabDate = grabbed, + // Off the grid entirely (the gutter, the header): keep the last cell + // it was over, so a wobble past the edge doesn't drop the target. + targetDate = resolved ?: drag?.targetDate, + topLeftInRoot = pointer - grab, + sizePx = sizePx, + ) + } +} + +@Composable +fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() } + +val LocalMonthDrag = compositionLocalOf { null } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 3152cf1..f34eb4d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.key import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.mutableIntStateOf @@ -78,6 +79,23 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import kotlin.math.roundToInt +import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface +import de.jeanlucmakiola.calendula.ui.common.eventMoveAction +import de.jeanlucmakiola.calendula.ui.common.MoveTarget +import de.jeanlucmakiola.calendula.ui.common.MoveRequest +import de.jeanlucmakiola.calendula.ui.common.LocalEventMove +import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA +import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics +import de.jeanlucmakiola.calendula.ui.common.EventMoveScope +import de.jeanlucmakiola.calendula.domain.spanFirstDay +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke @@ -384,13 +402,21 @@ fun MonthScreen( ) }, ) { innerPadding -> - Column( + // Hoisted above every style's grid: a dragged chip is drawn in an + // overlay so it is free of the week row's clip and, in the scrolling + // styles, of the list viewport's. + val chipDrag = rememberMonthDragController() + Box( modifier = Modifier .padding(innerPadding) .fillMaxSize(), ) { + Column(modifier = Modifier.fillMaxSize()) { WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) - CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { + CompositionLocalProvider( + LocalDimCutoff provides dimCutoff, + LocalMonthDrag provides chipDrag, + ) { if (scrolling) { ContinuousMonthContent( state = continuousState, @@ -427,10 +453,56 @@ fun MonthScreen( } } } + MonthDragOverlay(chipDrag) + } } } } +/** The chip in flight, drawn over the grid and following the finger. */ +@Composable +private fun MonthDragOverlay(controller: MonthDragController) { + var origin by remember { mutableStateOf(Offset.Zero) } + val dark = isSystemInDarkTheme() + val density = LocalDensity.current + val reduceMotion = rememberReduceMotion() + // Here rather than beside the controller: this reads [drag], which changes + // every frame, and this composable is the one that is meant to. + DragSnapHaptics(controller.drag?.targetDate) + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { origin = it.positionInRoot() }, + ) { + val drag = controller.drag ?: return@Box + MonthBar( + event = drag.event, + dark = dark, + continuesLeft = false, + continuesRight = false, + modifier = Modifier + .offset { + IntOffset( + (drag.topLeftInRoot.x - origin.x).roundToInt(), + (drag.topLeftInRoot.y - origin.y).roundToInt(), + ) + } + .width(with(density) { drag.sizePx.width.toDp() }) + .height(with(density) { drag.sizePx.height.toDp() }) + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp) + .graphicsLayer { + if (!reduceMotion) { + scaleX = 1.04f + scaleY = 1.04f + } + shadowElevation = 8.dp.toPx() + shape = RoundedCornerShape(4.dp) + clip = false + }, + ) + } +} + @Composable private fun MonthContent( state: MonthUiState, @@ -1720,6 +1792,22 @@ private fun MonthWeekRow( val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) val morphing = morphInFlight() + // Drag to reschedule (#68). The chips can take no pointer input of their own — + // the full-bleed tap layer below sits on top of them, and Compose stops + // sibling hit-testing at the topmost hit — so one detector on the row's day- + // column box hit-tests them geometrically instead. + val moveScope = LocalEventMove.current + val dragController = LocalMonthDrag.current + val rowToken = remember { Any() } + val bandCoordinates = remember { arrayOfNulls(1) } + val density = LocalDensity.current + val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() } + val zone = remember { TimeZone.currentSystemDefault() } + val dragging = dragController?.isDragging == true + DisposableEffect(rowToken, dragController) { + onDispose { dragController?.removeRow(rowToken) } + } + Row(modifier) { // Optional calendar-week gutter, sized so the seven day columns below // divide the remaining width — the absolute bar offsets stay correct @@ -1735,7 +1823,27 @@ private fun MonthWeekRow( BoxWithConstraints( Modifier .weight(1f) - .fillMaxHeight(), + .fillMaxHeight() + .onGloballyPositioned { coords -> + dragController?.putRow( + rowToken, + MonthRowGeometry( + days = week.days, + cell = coords, + columnWidthPx = coords.size.width / 7f, + ), + ) + } + .then( + monthChipDragModifier( + week = week, + moveScope = moveScope, + controller = dragController, + band = bandCoordinates, + rowHeightPx = rowHeightPx, + zone = zone, + ), + ), ) { val colW = maxWidth / 7 @@ -1800,7 +1908,10 @@ private fun MonthWeekRow( modifier = Modifier .fillMaxWidth() .weight(1f) - .then(if (morphing) Modifier else Modifier.clipToBounds()), + .onGloballyPositioned { bandCoordinates[0] = it } + // A dragged chip travels to another row, so the clip has + // to yield for it exactly as it does for a morph. + .then(if (morphing || dragging) Modifier else Modifier.clipToBounds()), ) { // Spanning bars on their shared lanes. week.spans.filter { it.lane < shownLanes }.forEach { span -> @@ -1959,6 +2070,74 @@ private fun MonthWeekRow( } } +/** + * The row-level pickup for month chips: it resolves which chip the press landed + * on from the geometry the row just laid out, and abandons the gesture when the + * press was on empty space (or on an event whose calendar can't be moved), so + * tapping a day still opens it. + */ +@Composable +private fun monthChipDragModifier( + week: MonthWeek, + moveScope: EventMoveScope?, + controller: MonthDragController?, + band: Array, + rowHeightPx: Float, + zone: TimeZone, +): Modifier = rememberDragSurface( + enabled = moveScope != null && controller != null, + key = week.days.first(), + onPickUp = { local, pointerInRoot, nodeInRoot, size -> + val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y + val columnPx = size.width / 7f + val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y) + val lane = (bandY / rowHeightPx).toInt() + val column = (local.x / columnPx).toInt() + val event = if (bandY < 0f || columnPx <= 0f) { + null + } else { + week.chipAt(column, lane, MAX_EVENT_ROWS) + } + if (event == null || moveScope?.allows(event) != true) { + false + } else { + controller?.begin( + event = event, + grabDate = week.days[column], + pointerInRoot = pointerInRoot, + chipInRoot = Offset( + x = nodeInRoot.x + column * columnPx, + y = requireNotNull(bandTop) + lane * rowHeightPx, + ), + size = IntSize(columnPx.toInt(), rowHeightPx.toInt()), + ) + true + } + }, + onMove = { controller?.move(it) }, + onDrop = { + controller?.finish()?.let { drop -> + val delta = drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays() + if (delta != 0L) { + 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), + ), + ), + ) + } + } + }, + onCancel = { controller?.cancel() }, +) + /** * Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the * day cells' geometry, set apart by the secondaryContainer tint (matching the @@ -2057,6 +2236,9 @@ private fun MonthBar( val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val soften = LocalSoftenColors.current val fill = eventFill(event.color, dark, soften) + val moveAction = eventMoveAction(event) + // The source stays put as a ghost while its floating copy travels. + val lifted = LocalMonthDrag.current?.liftedInstanceId == event.instanceId val shape = RoundedCornerShape( topStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp, @@ -2065,9 +2247,13 @@ private fun MonthBar( ) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) + .then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier) .background(fill, shape) .padding(horizontal = 4.dp) - .semantics { contentDescription = title }, + .semantics { + contentDescription = title + if (moveAction != null) customActions = listOf(moveAction) + }, contentAlignment = Alignment.CenterStart, ) { Text( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index ac3c7e4..bb9c629 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -67,6 +67,26 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List= 0 } ?: return null + return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index) +} + /** * The events on [day] that [laneEvents] had no lane left for — its exact * complement, in the same bars-then-pills order. Returned as events rather than diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index f4ff867..2c81ee9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -62,9 +62,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape 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.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.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -84,6 +86,18 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA +import de.jeanlucmakiola.calendula.ui.common.LocalEventMove +import de.jeanlucmakiola.calendula.ui.common.MoveRequest +import de.jeanlucmakiola.calendula.ui.common.MoveTarget +import de.jeanlucmakiola.calendula.ui.common.TimelineDragController +import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay +import de.jeanlucmakiola.calendula.ui.common.TimelineDrop +import de.jeanlucmakiola.calendula.ui.common.beginsOn +import de.jeanlucmakiola.calendula.ui.common.eventMoveAction +import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource +import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController +import de.jeanlucmakiola.calendula.ui.common.startInstant import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill @@ -128,6 +142,8 @@ private val GUTTER_WIDTH = 48.dp private val GUTTER_CONTENT_START_INSET = 8.dp private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp +/** Gap between day columns; part of the column pitch a drag maps positions through. */ +private val COLUMN_GAP = 2.dp /** Total all-day strip height for a week (0 when there are no all-day events). */ private fun WeekUiState.Success.allDayStripHeight(): Dp { @@ -331,32 +347,52 @@ private fun WeekContent( // gestures coexist without fighting. val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) - AnimatedContent( - targetState = state, - modifier = modifier.then(swipeModifier), - contentKey = { s -> + // Above the AnimatedContent on purpose: a page change mid-drag would strand + // the floating block inside the outgoing page. + val dragController = rememberTimelineDragController() + val move = LocalEventMove.current + val zone = remember { TimeZone.currentSystemDefault() } + + Box(modifier = modifier) { + AnimatedContent( + targetState = state, + modifier = Modifier.fillMaxSize().then(swipeModifier), + contentKey = { s -> + when (s) { + is WeekUiState.Success -> "success-${s.weekStart}" + is WeekUiState.Failure -> "failure-${s.reason}" + WeekUiState.Loading -> "loading" + } + }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, + label = "week-transition", + ) { s -> when (s) { - is WeekUiState.Success -> "success-${s.weekStart}" - is WeekUiState.Failure -> "failure-${s.reason}" - WeekUiState.Loading -> "loading" + WeekUiState.Loading -> WeekLoading() + is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) + is WeekUiState.Success -> WeekSuccess( + state = s, + topSectionColor = topSectionColor, + scrollState = scrollState, + allDayHeight = allDayHeight, + dragController = dragController, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = { drop -> + move?.move( + MoveRequest( + eventId = drop.event.eventId, + beginMillis = drop.event.start.toEpochMilliseconds(), + endMillis = drop.event.end.toEpochMilliseconds(), + target = MoveTarget.Start(drop.startInstant(zone)), + ), + ) + }, + ) } - }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, - label = "week-transition", - ) { s -> - when (s) { - WeekUiState.Loading -> WeekLoading() - is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is WeekUiState.Success -> WeekSuccess( - state = s, - topSectionColor = topSectionColor, - scrollState = scrollState, - allDayHeight = allDayHeight, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onCreateAt = onCreateAt, - ) } + TimelineDragOverlay(dragController) } } @@ -366,9 +402,11 @@ private fun WeekSuccess( topSectionColor: Color, scrollState: ScrollState, allDayHeight: Dp, + dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onOpenDay: (LocalDate) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { Column( @@ -385,8 +423,10 @@ private fun WeekSuccess( Timeline( state = state, scrollState = scrollState, + dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, + onDrop = onDrop, ) } } @@ -622,13 +662,16 @@ private fun AllDayBar( private fun Timeline( state: WeekUiState.Success, scrollState: ScrollState, + dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, ) { val dark = isSystemInDarkTheme() val use24Hour = LocalUse24HourFormat.current val locale = currentLocale() val zoom = LocalTimelineZoom.current + val density = LocalDensity.current // 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 @@ -680,13 +723,28 @@ private fun Timeline( .weight(1f) .fillMaxHeight() .clip(RoundedCornerShape(16.dp)) - .verticalScroll(scrollState), + .verticalScroll(scrollState) + .onGloballyPositioned { dragController.geometry.viewport = it }, ) { Row( modifier = Modifier .fillMaxWidth() - .height(totalHeight), - horizontalArrangement = Arrangement.spacedBy(2.dp), + .height(totalHeight) + // The scrolling content itself, so its root position + // already folds in the scroll offset — a drag maps + // through it without reading the scroll state. + .onGloballyPositioned { coords -> + val gap = with(density) { COLUMN_GAP.toPx() } + dragController.geometry.let { + it.grid = coords + it.scroll = scrollState + it.hourPx = with(density) { hourHeight.toPx() } + it.columnGapPx = gap + it.columnWidthPx = (coords.size.width + gap) / state.days.size + it.days = state.days + } + }, + horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP), ) { state.days.forEach { day -> DayColumnCard( @@ -695,8 +753,10 @@ private fun Timeline( date = day, today = state.today, hourHeight = hourHeight, + dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, + onDrop = onDrop, modifier = Modifier .weight(1f) .fillMaxHeight(), @@ -715,8 +775,10 @@ private fun DayColumnCard( date: LocalDate, today: LocalDate, hourHeight: Dp, + dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, modifier: Modifier = Modifier, ) { val hourPx = with(LocalDensity.current) { hourHeight.toPx() } @@ -758,7 +820,10 @@ private fun DayColumnCard( dark = dark, height = height, width = laneWidth, + date = date, + dragController = dragController, onClick = { onEventClick(block.event) }, + onDrop = onDrop, modifier = Modifier .offset(x = laneWidth * block.lane, y = top) .width(laneWidth) @@ -780,7 +845,10 @@ private fun EventBlock( dark: Boolean, height: Dp, width: Dp, + date: LocalDate, + dragController: TimelineDragController, onClick: () -> Unit, + onDrop: (TimelineDrop) -> Unit, modifier: Modifier = Modifier, ) { val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } @@ -823,12 +891,33 @@ private fun EventBlock( val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) val soften = LocalSoftenColors.current val fill = eventFill(block.event.color, dark, soften) + val zone = remember { TimeZone.currentSystemDefault() } + 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 dragModifier = rememberEventDragSource( + enabled = moveAction != null && block.beginsOn(date, zone), + 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 Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) + // The source stays put as a ghost while its floating copy travels. + .then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier) .background(fill, RoundedCornerShape(4.dp)) .clickable(onClick = onClick) + // After clickable, so it is the inner node and wins the main pass; + // the tap still works, since a drag consumes the up. + .then(dragModifier) .padding(horizontal = 4.dp, vertical = 2.dp) - .semantics { contentDescription = "$title, $timeLabel" }, + .semantics { + contentDescription = "$title, $timeLabel" + if (moveAction != null) customActions = listOf(moveAction) + }, ) { Column { if (showTitle) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e296d4..65451ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,6 +65,20 @@ Edit recurring event Couldn\'t delete the event Calendula needs write access to delete events + + + Move… + Move recurring event + This series picks its days in a way that can\'t be recalculated from one moved event, so only this event can move. + + Moved to %1$s + Undo + Move undone + Couldn\'t move the event + Calendula needs write access to move events + That event no longer exists + Can\'t move an event past the end of its series + Cancel OK diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 596d35b..8c9c123 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.realignRecurrence import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -285,6 +286,66 @@ class EventWriteMapperTest { .isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin")) } + @Test + fun `a weekday move must carry the rule with the anchor, or the series stays put`() { + // The anchor moves by the same shift as the occurrence, so a Monday + // series lands on a Wednesday — while RRULE is written verbatim. Without + // realignRecurrence the rule would still say Monday and nothing moves. + val series = instantAt("2026-01-05T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY;BYDAY=MO") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0)), + rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)), + ) + + val values = update(original, moved, series) + + val anchor = java.time.Instant.ofEpochMilli(values[CalendarContract.Events.DTSTART] as Long) + .atZone(java.time.ZoneId.of("Europe/Berlin")) + assertThat(anchor.dayOfWeek).isEqualTo(java.time.DayOfWeek.WEDNESDAY) + assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY;BYDAY=WE") + } + + @Test + fun `a recurring move keeps the series' length across a DST boundary`() { + // The occurrence being dragged is in CET; the series anchor is in CEST. + val series = instantAt("2026-07-15T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)), + ).copy(rrule = "FREQ=WEEKLY") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30)), + ) + + assertThat(update(original, moved, series)[CalendarContract.Events.DURATION]) + .isEqualTo("P5400S") + } + + @Test + fun `shifting a one-off event and back again restores the original values`() { + val original = form() + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0)), + end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0)), + ) + + val forward = update(original, moved) + val back = update(moved, original) + + assertThat(back[CalendarContract.Events.DTSTART]) + .isEqualTo(original.toWriteTimes(berlin).dtStartMillis) + assertThat(back[CalendarContract.Events.DTEND]) + .isEqualTo(original.toWriteTimes(berlin).dtEndMillis) + assertThat(forward[CalendarContract.Events.DTSTART]) + .isNotEqualTo(back[CalendarContract.Events.DTSTART]) + } + @Test fun `switching a recurring event to all-day anchors the series on a UTC midnight`() { val series = instantAt("2026-01-07T09:00", "Europe/Berlin") diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt index 376bcd0..33b9ed4 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/CalendarRowStateTest.kt @@ -94,6 +94,27 @@ class CalendarRowStateTest { assertThat(calendar.isEventTarget).isFalse() } + @Test + fun `a managed calendar's events are editable but never movable`() { + // Their date is owned by the contacts sync, so a drag would be undone. + val managed = cal().copy(isManaged = true) + assertThat(managed.allowsEventMove).isFalse() + } + + @Test + fun `a read-only calendar's events are not movable`() { + assertThat(cal().copy(canModifyContents = false).allowsEventMove).isFalse() + } + + @Test + fun `a switched-off calendar's events stay movable, unlike a new-event target`() { + // Nothing renders them, so the question is moot — but the predicate is + // deliberately not isEventTarget, which would also exclude them. + val hidden = cal().copy(isVisibleInSystem = false) + assertThat(hidden.isEventTarget).isFalse() + assertThat(hidden.allowsEventMove).isTrue() + } + @Test fun `manager order puts non-syncing calendars last and is otherwise stable`() { val ordered = listOf( diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt new file mode 100644 index 0000000..fc45e8f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -0,0 +1,137 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +class EventShiftTest { + + private val berlin = TimeZone.of("Europe/Berlin") + private val newYork = TimeZone.of("America/New_York") + + private fun form( + start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)), + end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)), + timezone: String? = null, + isAllDay: Boolean = false, + ) = EventForm( + calendarId = 1L, + title = "Standup", + isAllDay = isAllDay, + start = start, + end = end, + timezone = timezone, + ) + + @Test + fun `shifting to a new start keeps the length`() { + val original = form() + val target = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)).toInstant(berlin) + + val moved = original.shiftedTo(target, berlin) + + assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30))) + assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30))) + } + + @Test + fun `a pinned event is re-derived in its own zone, not the device's`() { + // 09:00 in New York, opened on a Berlin device. + val original = form( + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)), + timezone = "America/New_York", + ) + // The grid says "15:30 Berlin", which is 09:30 in New York. + val target = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin) + + val moved = original.shiftedTo(target, berlin) + + assertThat(moved.timezone).isEqualTo("America/New_York") + assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30))) + assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30))) + } + + @Test + fun `the instant duration survives a shift across spring forward`() { + // 2026-03-29 02:00 CET is when Berlin skips to 03:00. + val original = form( + start = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(23, 0)), + end = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)), + ) + val target = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)).toInstant(berlin) + + val moved = original.shiftedTo(target, berlin) + + // Two real hours from 01:00 CET lands at 04:00 CEST, not 03:00: the wall + // clock stretches because the hour in between does not exist. + assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0))) + assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0))) + assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin)) + .isEqualTo(original.end.toInstant(berlin) - original.start.toInstant(berlin)) + } + + @Test + fun `a drop into the spring-forward gap resolves forward by the missing hour`() { + val original = form() + // 02:30 does not exist on 2026-03-29 in Berlin. + val target = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)).toInstant(berlin) + + val moved = original.shiftedTo(target, berlin) + + assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30))) + } + + @Test + fun `a zero-distance drop leaves the form untouched`() { + val original = form() + assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original) + } + + @Test + fun `an unparseable pinned zone falls back to the device, like the write path`() { + val original = form(timezone = "Mars/Olympus") + val target = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)).toInstant(newYork) + + val moved = original.shiftedTo(target, newYork) + + assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0))) + } + + @Test + fun `an all-day event ignores shiftedTo and moves by whole days instead`() { + val allDay = form(isAllDay = true) + val target = LocalDateTime(LocalDate(2026, 7, 1), LocalTime(3, 0)).toInstant(berlin) + + assertThat(allDay.shiftedTo(target, berlin)).isEqualTo(allDay) + + val moved = allDay.shiftedByDays(3) + assertThat(moved.start.date).isEqualTo(LocalDate(2026, 6, 14)) + assertThat(moved.end.date).isEqualTo(LocalDate(2026, 6, 14)) + // The placeholder times exist only for a switch back to timed; untouched. + assertThat(moved.start.time).isEqualTo(allDay.start.time) + } + + @Test + fun `a multi-day event keeps its span when shifted by days`() { + val multiDay = form( + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(22, 0)), + end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(2, 0)), + ) + + val moved = multiDay.shiftedByDays(-2) + + assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0))) + assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0))) + } + + @Test + fun `shifting by no days is a no-op`() { + val original = form() + assertThat(original.shiftedByDays(0)).isEqualTo(original) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealignTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealignTest.kt new file mode 100644 index 0000000..0f6a1b6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealignTest.kt @@ -0,0 +1,90 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class RecurrenceRealignTest { + + private val monday = LocalDate(2026, 6, 8) + private val wednesday = LocalDate(2026, 6, 10) + + @Test + fun `a weekly BYDAY rule follows the occurrence to its new weekday`() { + assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO", monday, wednesday)) + .isEqualTo("FREQ=WEEKLY;BYDAY=WE") + } + + @Test + fun `unrelated parts survive the rewrite`() { + assertThat( + realignRecurrence("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;COUNT=10", monday, wednesday), + ).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE;COUNT=10") + } + + @Test + fun `a monthly BYMONTHDAY rule follows the day of the month`() { + assertThat( + realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday), + ).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=10") + } + + @Test + fun `a yearly rule realigns both month and day`() { + assertThat( + realignRecurrence( + "FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", + monday, + LocalDate(2026, 7, 20), + ), + ).isEqualTo("FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=20") + } + + @Test + fun `a rule with no day-selecting part needs no rewrite`() { + assertThat(realignRecurrence("FREQ=DAILY;INTERVAL=3", monday, wednesday)) + .isEqualTo("FREQ=DAILY;INTERVAL=3") + assertThat(realignRecurrence("FREQ=WEEKLY", monday, wednesday)).isEqualTo("FREQ=WEEKLY") + } + + @Test + fun `a rule the move does not disturb comes back verbatim`() { + val rule = "FREQ=WEEKLY;BYDAY=MO,WE,FR" + assertThat(realignRecurrence(rule, monday, monday)).isEqualTo(rule) + } + + @Test + fun `a multi-day BYDAY cannot be resolved from one moved occurrence`() { + assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO,WE", monday, wednesday)).isNull() + } + + @Test + fun `an ordinal BYDAY cannot be resolved`() { + assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=2MO", monday, wednesday)).isNull() + } + + @Test + fun `a BYDAY that does not name the occurrence's own weekday is refused`() { + // The rule and DTSTART already disagree; guessing would make it worse. + assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=TU", monday, wednesday)).isNull() + } + + @Test + fun `parts we cannot reason about are refused rather than guessed`() { + assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1", monday, wednesday)) + .isNull() + assertThat(realignRecurrence("FREQ=YEARLY;BYYEARDAY=159", monday, wednesday)).isNull() + } + + @Test + fun `a leading RRULE prefix is preserved`() { + assertThat(realignRecurrence("RRULE:FREQ=WEEKLY;BYDAY=MO", monday, wednesday)) + .isEqualTo("RRULE:FREQ=WEEKLY;BYDAY=WE") + } + + @Test + fun `a malformed rule is refused`() { + assertThat(realignRecurrence("FREQ=WEEKLY;GARBAGE", monday, wednesday)).isNull() + assertThat(realignRecurrence("", monday, wednesday)).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt new file mode 100644 index 0000000..176f999 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -0,0 +1,391 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl +import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.time.Instant + +/** + * The drop pipeline: which repository call a scope maps to, when a recurring drop + * has to ask first, and every case that must refuse to write. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class RescheduleViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeEach fun setUp() = Dispatchers.setMain(dispatcher) + @AfterEach fun tearDown() = Dispatchers.resetMain() + + // Monday 2026-06-08, midday UTC — midday so the device zone can't move the + // weekday out from under the BYDAY assertions. + private val monday = LocalDate(2026, 6, 8) + private val wednesday = LocalDate(2026, 6, 10) + private val beginMillis = + LocalDateTime(monday, LocalTime(12, 0)).toInstant(TimeZone.UTC).toEpochMilliseconds() + private val endMillis = beginMillis + 3_600_000L + + private fun cal( + id: Long, + canModify: Boolean = true, + managed: Boolean = false, + ): CalendarSource = CalendarSource( + id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL", + color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = canModify, + isManaged = managed, + ) + + private fun detail( + rrule: String? = null, + isAllDay: Boolean = false, + isException: Boolean = false, + ): EventDetail = EventDetail( + instance = EventInstance( + instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup", + start = Instant.fromEpochMilliseconds(beginMillis), + end = Instant.fromEpochMilliseconds(endMillis), + isAllDay = isAllDay, color = 0xFF000000.toInt(), location = null, + ), + description = null, organizer = null, attendees = emptyList(), rrule = rrule, + isException = isException, + ) + + private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel { + val prefs = CalendarPrefs( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.resolve("move_prefs.preferences_pb").toFile() }, + ), + ) + val settings = SettingsPrefs( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.resolve("move_settings.preferences_pb").toFile() }, + ), + ) + val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher) + return RescheduleViewModel(repo, dispatcher) + } + + private fun CoroutineScope.activate(vm: RescheduleViewModel): Job = + launch { vm.movableCalendarIds.collect {} } + + /** A drop two days later, expressed the way the month grid expresses one. */ + private fun toWednesday() = MoveRequest( + eventId = 42L, + beginMillis = beginMillis, + endMillis = endMillis, + target = MoveTarget.Day(wednesday), + ) + + /** A drop an hour later, expressed the way a timeline drag expresses one. */ + private fun oneHourLater() = MoveRequest( + eventId = 42L, + beginMillis = beginMillis, + endMillis = endMillis, + target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)), + ) + + @Test + fun `a one-off drop writes straight through with no scope prompt`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isNull() + assertThat(fake.updatedEvents).hasSize(1) + val (id, original, updated) = fake.updatedEvents.single() + assertThat(id).isEqualTo(42L) + assertThat(updated.start).isNotEqualTo(original.start) + assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java) + } + + @Test + fun `a recurring drop parks for the scope instead of writing`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } + } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false)) + assertThat(fake.updatedEvents).isEmpty() + assertThat(fake.updatedOccurrences).isEmpty() + } + + @Test + fun `each scope maps to its own repository call`(@TempDir tempDir: Path) = runTest(dispatcher) { + val occurrence = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } + } + viewModel(tempDir.resolve("a").also { it.toFile().mkdirs() }, occurrence).run { + move(oneHourLater()) + moveWithScope(RecurringWriteScope.ThisEvent) + } + advanceUntilIdle() + assertThat(occurrence.updatedOccurrences).hasSize(1) + assertThat(occurrence.updatedOccurrences.single().second).isEqualTo(beginMillis) + + val following = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } + } + viewModel(tempDir.resolve("b").also { it.toFile().mkdirs() }, following).run { + move(oneHourLater()) + moveWithScope(RecurringWriteScope.ThisAndFollowing) + } + advanceUntilIdle() + assertThat(following.updatedFromOccurrences).hasSize(1) + + val series = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } + } + viewModel(tempDir.resolve("c").also { it.toFile().mkdirs() }, series).run { + move(oneHourLater()) + moveWithScope(RecurringWriteScope.AllEvents) + } + advanceUntilIdle() + assertThat(series.updatedEvents).hasSize(1) + } + + @Test + fun `a weekly BYDAY rule is realigned when the whole series moves weekday`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") } + } + val vm = viewModel(tempDir, fake) + + vm.move(toWednesday()) + vm.moveWithScope(RecurringWriteScope.AllEvents) + advanceUntilIdle() + + // Without this the anchor becomes a Wednesday while the rule still says + // Monday, and the series does not move at all. + assertThat(fake.updatedEvents.single().third.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=WE") + } + + @Test + fun `a rule that cannot be realigned offers only the single occurrence`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE") } + } + val vm = viewModel(tempDir, fake) + + vm.move(toWednesday()) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true)) + } + + @Test + fun `a same-time drop within one day still needs no realignment`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE") } + } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + advanceUntilIdle() + + // The weekday is unchanged, so the ambiguous BYDAY is not in the way. + assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false)) + } + + @Test + fun `an exception row is written as a plain event, never as a nested exception`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + // A sync adapter that left the series' rule on the override row. + eventDetailResult = { detail(rrule = "FREQ=WEEKLY", isException = true) } + } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isNull() + assertThat(fake.updatedEvents).hasSize(1) + assertThat(fake.updatedOccurrences).isEmpty() + } + + @Test + fun `a drop past the series' own UNTIL is refused, not written`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + // Midday UTC, so the device zone can't push the UNTIL date onto the + // day the event is being dragged to. + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") } + } + val vm = viewModel(tempDir, fake) + + vm.move(toWednesday()) + advanceUntilIdle() + + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd) + assertThat(vm.scopePrompt.value).isNull() + assertThat(fake.updatedEvents).isEmpty() + } + + @Test + fun `a zero-distance drop writes nothing and says nothing`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } } + val vm = viewModel(tempDir, fake) + + vm.move( + MoveRequest( + eventId = 42L, + beginMillis = beginMillis, + endMillis = endMillis, + target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)), + ), + ) + advanceUntilIdle() + + assertThat(fake.updatedEvents).isEmpty() + assertThat(vm.outcome.value).isNull() + } + + @Test + fun `a vanished event reports itself as gone`(@TempDir tempDir: Path) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { null } } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + advanceUntilIdle() + + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone) + } + + @Test + fun `a revoked write permission is reported apart from a plain failure`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail() } + writeError = SecurityException("revoked") + } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + advanceUntilIdle() + + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied) + } + + @Test + fun `undo writes the move back the other way`(@TempDir tempDir: Path) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + advanceUntilIdle() + val undo = (vm.outcome.value as MoveOutcome.Moved).undo + assertThat(undo).isNotNull() + + vm.undo(undo!!) + advanceUntilIdle() + + assertThat(fake.updatedEvents).hasSize(2) + val forward = fake.updatedEvents[0] + val back = fake.updatedEvents[1] + assertThat(back.second).isEqualTo(forward.third) + assertThat(back.third).isEqualTo(forward.second) + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone) + } + + @Test + fun `an occurrence write offers no undo, since shifting back cannot restore it`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } + } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + vm.moveWithScope(RecurringWriteScope.ThisEvent) + advanceUntilIdle() + + assertThat((vm.outcome.value as MoveOutcome.Moved).undo).isNull() + } + + @Test + fun `only writable, unmanaged calendars can be dragged from`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf( + cal(1L), + cal(2L, canModify = false), + cal(3L, managed = true), + ) + } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + advanceUntilIdle() + + assertThat(vm.movableCalendarIds.value).containsExactly(1L) + job.cancel() + } + + @Test + fun `cancelling the scope dialog writes nothing`(@TempDir tempDir: Path) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } + } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + vm.cancelScope() + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isNull() + assertThat(fake.updatedEvents).isEmpty() + assertThat(fake.updatedOccurrences).isEmpty() + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c56ffab..1e73265 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,6 +108,37 @@ on-device): UTC, so zones ahead of UTC can't leak an extra occurrence. - All-day events are normalised to UTC midnights with an exclusive end. +### Drag to reschedule + +Dropping an event on another slot (#68) is not a second write path: the drop +loads the event, prefills the **same** `toEditForm` the edit screen uses, shifts +it (`EventShift.kt`), and dispatches through the same three repository calls a +save does — so recurring writes, reminder reconciliation and attendee +preservation behave identically. The full form is carried through, never a +stripped one: `updateOccurrence` reconciles reminders *and* attendees onto the +new exception row, and a partial form would wipe them. + +Two things the drag has to get right that the edit screen sidesteps: + +- **`RRULE` day parts go stale.** `buildEventUpdateValues` writes the rule + verbatim while `DTSTART` moves, so dragging a `FREQ=WEEKLY;BYDAY=MO` occurrence + onto a Wednesday under *All events* leaves a Wednesday anchor under a Monday + rule and the series does not move. `realignRecurrence` re-derives `BYDAY` / + `BYMONTHDAY` / `BYMONTH` from the new date, and returns **null** for rules one + moved occurrence cannot resolve (`BYDAY=MO,WE`, `2TH`, `BYSETPOS`) — the drop + then offers only *this event*, whose exception row carries no rule at all. The + same staleness is reachable from the edit screen; wiring it there too is a + separate change. +- **The eligibility gate is load-bearing.** Nothing below the UI refuses a + write, so `CalendarSource.allowsEventMove` is what keeps read-only and + contact-managed events from being dragged. It is deliberately *not* + `isEventTarget`: a managed event is editable (reminders, notes) yet must never + move, while a switched-off calendar renders nothing to grab anyway. + +`EventForm.problems()` runs before the write, so a drop that would push a series +past its own `UNTIL` — which makes the provider generate zero occurrences and the +event vanish — is refused rather than written. + ### Event time zones `EventForm.timezone` is the zone its wall-clock times mean, and **null means @@ -144,6 +175,19 @@ changes to untouched fields survive either way. Fields the form cannot write (attendees, status, reminder methods) are excluded so sync noise can't fake a conflict. +A **dropped** event gets no conflict dialog, deliberately. Its blast radius is +already bounded by the same dirty check — only `ALL_DAY`, `EVENT_TIMEZONE`, +`DTSTART` and `DTEND`/`DURATION`/`RRULE` are written — so a concurrent remote +edit to the title, notes or guests survives untouched. What a drop *can* clobber +is a concurrent remote **time** change, and parking a one-gesture action behind a +modal would cost more than that case is worth; the undo in the confirmation +snackbar is the answer instead. Undo restores semantics, not the row's byte +shape (`DURATION` normalises to `PS`/`PD`, `EVENT_TIMEZONE` is stamped +concrete), and it is offered only where the inverse is one symmetric write — a +one-off event or a whole-series shift. *This event* leaves an exception row +behind and *this and following* splits the series; neither is undone by shifting +back, so both get a plain confirmation. + ## Reminder delivery Calendula plans and fires its own reminders. It reads the offsets in