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