From 4e38866dfd9a19ff1ba51f7af777f7b917b49957 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 20:36:09 +0200 Subject: [PATCH] Drag an event to reschedule it (#135) Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/135 --- .../data/calendar/EventDetailMapper.kt | 1 + .../calendula/data/calendar/Projections.kt | 4 + .../calendula/domain/CalendarRowState.kt | 8 + .../calendula/domain/EventShift.kt | 53 ++ .../jeanlucmakiola/calendula/domain/Models.kt | 6 + .../calendula/domain/Recurrence.kt | 2 +- .../calendula/domain/RecurrenceRealign.kt | 48 ++ .../calendula/ui/CalendarHost.kt | 34 ++ .../calendula/ui/common/BlockPlacement.kt | 75 +++ .../calendula/ui/common/EventDrag.kt | 150 ++++++ .../calendula/ui/common/EventMoveHost.kt | 144 +++++ .../calendula/ui/common/EventMoveScope.kt | 109 ++++ .../calendula/ui/common/HourGutter.kt | 119 +++++ .../ui/common/RecurringScopeDialog.kt | 69 +++ .../ui/common/RescheduleViewModel.kt | 463 ++++++++++++++++ .../calendula/ui/common/TimeFormat.kt | 13 + .../calendula/ui/common/TimelineDrag.kt | 484 +++++++++++++++++ .../calendula/ui/common/TimelineZoom.kt | 15 +- .../calendula/ui/day/DayScreen.kt | 243 ++++++--- .../calendula/ui/edit/EventEditScreen.kt | 30 +- .../calendula/ui/month/MonthDrag.kt | 303 +++++++++++ .../calendula/ui/month/MonthScreen.kt | 292 +++++++++- .../calendula/ui/month/MonthUiState.kt | 17 + .../calendula/ui/week/WeekScreen.kt | 252 ++++++--- app/src/main/res/values/strings.xml | 14 + .../data/calendar/EventWriteMapperTest.kt | 83 +++ .../calendula/domain/CalendarRowStateTest.kt | 20 + .../calendula/domain/EventShiftTest.kt | 155 ++++++ .../calendula/domain/RecurrenceRealignTest.kt | 111 ++++ .../ui/common/RescheduleViewModelTest.kt | 497 ++++++++++++++++++ .../calendula/ui/common/TimeFormatTest.kt | 8 + docs/ARCHITECTURE.md | 93 ++++ floret-kit | 2 +- 33 files changed, 3721 insertions(+), 196 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurringScopeDialog.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealignTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt index cbf572d..df22fa9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -125,6 +125,7 @@ internal fun ColumnReader.toEventDetailCore( selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)), eventColor = eventColor, eventColorKey = eventColorKey, + isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 21eb4e7..ac3e138 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,9 @@ 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; "no RRULE" alone + // can't tell an exception from a master (#68). + CalendarContract.Events.ORIGINAL_ID, ) const val IDX_EVENT_ID = 0 @@ -110,6 +113,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..981b7e3 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,14 @@ 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]: a managed event stays editable (reminders, + * notes) yet must never move, since 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..84a810a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -0,0 +1,53 @@ +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]. + */ +fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone = + timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone + +/** + * The form moved so it starts at [newStart], keeping its **instant** duration — + * a recurring event's length travels to the provider as `DURATION`, so keeping + * wall clock instead would rewrite the series' length across a DST boundary. + * + * All-day events are date-anchored — use [shiftedByDays]; 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 span. + * All-day events are pure date arithmetic; a timed one preserves its **instant** + * duration, for the same reason [shiftedTo] does. + */ +fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm { + if (days == 0) return this + val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time) + if (isAllDay) { + return copy( + start = newStart, + end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time), + ) + } + val zone = resolvedZone(deviceZone) + val span = end.toInstant(zone) - start.toInstant(zone) + return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone)) +} 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..90491de 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,12 @@ 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) rather than a master, so a reschedule 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..b55db7d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealign.kt @@ -0,0 +1,48 @@ +package de.jeanlucmakiola.calendula.domain + +import kotlinx.datetime.LocalDate + +/** + * [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart]. + * + * `Events.RRULE` is written verbatim while DTSTART moves, so `FREQ=WEEKLY;BYDAY=MO` + * would keep naming Monday after the anchor became a Wednesday and the series + * would not move at all — `BYDAY` is re-derived from [newStart]. + * + * Only weekly `BYDAY` is realigned, and only for a whole-day move: the rule has + * to agree with the series *anchor*, and weekday arithmetic is the only kind + * that survives the same wall-clock shift unchanged. Everything else returns + * null, as do rules one moved occurrence can't resolve (`BYDAY=MO,WE`, `2TH`, + * `BYSETPOS`). The accepted parts are a subset of [parseSimpleRecurrence], so + * anything realignable is also a rule [problems] can check the `UNTIL` of. + */ +fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? { + if (oldStart == newStart) return rrule + val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else "" + val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() } + if (parts.isEmpty()) return null + var weekly = false + val rebuilt = parts.map { part -> + val eq = part.indexOf('=') + if (eq <= 0) return null + val key = part.substring(0, eq).uppercase() + val value = part.substring(eq + 1).trim() + when (key) { + "FREQ" -> { + weekly = value.equals("WEEKLY", ignoreCase = true) + part + } + "BYDAY" -> { + val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null + if (!value.equals(old, ignoreCase = true)) return null + "BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}" + } + "INTERVAL", "COUNT", "UNTIL", "WKST" -> part + else -> return null + } + } + // BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence. + if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null + if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null + return prefix + rebuilt.joinToString(";") +} 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..f2ef6e5 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,29 @@ 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, + inFlight = reschedule.inFlight, + undoStarted = reschedule.undoStarted, + edit = onEditEvent, + ) + } + val slideSpec = rememberCalendarSlideSpec() // Base-level back: pop the view stack while no overlay covers it (each overlay @@ -311,6 +339,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 +396,11 @@ fun CalendarHost( ) } } + } + + // Scope prompt + confirmation/undo snackbar for a dropped event, declared + // right after the calendar views so later overlays cover it. + 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/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt new file mode 100644 index 0000000..24db4bf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -0,0 +1,75 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.snap +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import de.jeanlucmakiola.floret.identity.rememberReduceMotion + +/** + * A timed block's own time label, crossfaded rather than replaced — the block + * slides to its new slot, so the label shouldn't change in a single frame. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) { + val spec: FiniteAnimationSpec = if (rememberReduceMotion()) { + snap() + } else { + MaterialTheme.motionScheme.fastEffectsSpec() + } + Crossfade( + targetState = label, + animationSpec = spec, + label = "block-time", + modifier = modifier, + ) { text -> + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = color, + ) + } +} + +/** Where a timed block sits in its column, after tweening. */ +@Immutable +data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp) + +/** + * A timed block's placement, tweened rather than jumped. Continuity comes from + * the caller keying each block by identity; a block composed for the first time + * starts at its target, so nothing flies in on the first frame. + * + * A pinch-zoom rewrites the hour height every pointer frame, and the gutter and + * grid lines follow it instantly — so the tween stands down for the gesture + * rather than leaving the blocks trailing the ruler they are measured against. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement { + val spec: FiniteAnimationSpec = if (rememberReduceMotion() || + LocalTimelineZoom.current.isPinching + ) { + snap() + } else { + MaterialTheme.motionScheme.fastSpatialSpec() + } + val animatedX by animateDpAsState(x, spec, label = "block-x") + val animatedY by animateDpAsState(y, spec, label = "block-y") + val animatedWidth by animateDpAsState(width, spec, label = "block-width") + val animatedHeight by animateDpAsState(height, spec, label = "block-height") + return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight) +} 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..12bf70f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt @@ -0,0 +1,150 @@ +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 — well under touch slop. */ +private val PICKUP_TOLERANCE = 6.dp + +/** + * Pick an event block up with a long press and drag it, without + * `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an + * ancestor consumes or the finger leaves the block, which a + * `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout + * is deliberately not consumed, so a scroll starting on top of a block survives. + */ +@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. [onPickUp] receives the press position local to this node and answers + * whether anything is there; false abandons the gesture. + */ +@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 { + // Driven on the initial pass, which runs parent → child: an + // ancestor that outranks us (the pinch) has already consumed + // by the time we look, and descendants — the month grid's + // full-bleed tap layer — see our consumption. + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (change.isConsumed) break + if (!change.pressed) { + change.consume() + dropped = true + break + } + 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 — + // a cancel, not a drop, and must never write. + if (dropped) currentDrop() else currentCancel() + } + } + } +} + +/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */ +@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..2bed934 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -0,0 +1,144 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.components.SnackChip +import de.jeanlucmakiola.floret.components.SnackChipHeight +import de.jeanlucmakiola.floret.components.SnackChipMargin +import de.jeanlucmakiola.floret.locale.currentLocale +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import kotlinx.coroutines.delay +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.Locale + +/** How long the confirmation chip stays up, matching a short snackbar. */ +private const val CHIP_MILLIS = 4_000L + +/** How long an *undone* move stays up — shorter, since it offers nothing to act on. */ +private const val UNDONE_CHIP_MILLIS = 1_600L + +/** What the chip currently reads, kept past the outcome it was built from. */ +private data class ChipContent(val message: String, val undo: MoveUndo?) + +/** The FAB's own band at the bottom end, which the chip must not run into. */ +private val FAB_BAND = 88.dp + +/** + * The two surfaces a drag-and-drop reschedule needs on top of the calendar: the + * recurring-scope prompt, and the confirmation chip carrying Undo. None of the + * calendar screens sets a `snackbarHost`, so this hosts its own confirmation as + * a pill on the FAB's band, leaving the calendar visible behind it. + */ +@Composable +fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) { + val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle() + val outcome by viewModel.outcome.collectAsStateWithLifecycle() + val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle() + val writeInFlight by viewModel.inFlight.collectAsStateWithLifecycle() + 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 (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) + } + + // Held past the outcome being consumed so the chip has something to draw + // while it springs back out. Updated in composition rather than from an + // effect, which would land a frame late and open the chip on stale text. + val shown = remember { mutableStateOf(ChipContent("", null)) } + if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) { + shown.value = ChipContent(message, moved?.undo) + } + val content = shown.value + // Restarted by the undo tick and held while a write is in flight: an undo + // tapped in the last moments of the window leaves the outcome at Moved on + // purpose, and this timer would otherwise fire mid-undo and close the chip + // just before the same chip has to say "undone". + LaunchedEffect(outcome, undoTick, writeInFlight) { + if (outcome == null || writeInFlight) return@LaunchedEffect + delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS) + viewModel.consumeOutcome() + } + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val chipMaxWidth = maxWidth - FAB_BAND + // A FAB-height band at the bottom start with the FAB's own margin, so + // the chip lines up beside the bottom-end FAB. + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .padding(start = SnackChipMargin, bottom = SnackChipMargin) + .height(SnackChipHeight), + contentAlignment = Alignment.CenterStart, + ) { + // The action goes away while a write runs: undo() refuses a second + // write anyway, so offering it would be a button that does nothing. + val undo = content.undo?.takeIf { !writeInFlight } + SnackChip( + visible = outcome != null, + message = content.message, + maxWidth = chipMaxWidth, + actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null }, + onAction = undo?.let { { viewModel.undo(it) } }, + ) + } + } +} + +/** + * "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..6fb0d8a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt @@ -0,0 +1,109 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by + * whichever event block is being composed. Null means moving is off entirely and + * blocks register no drag gesture at all. + */ +@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, + /** False when the drop was refused outright, so nothing will be written. */ + val move: (MoveRequest) -> Boolean, + /** + * True while a dropped event is being written, including the time its scope + * dialog is up. A flow rather than a value so this scope stays the same + * object across a move — every visible block reads it as a composition local. + */ + val inFlight: StateFlow, + /** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */ + val undoStarted: StateFlow, + /** 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 } + +private val NEVER_IN_FLIGHT = MutableStateFlow(false) + +private val NEVER_UNDONE = MutableStateFlow(0) + +/** + * Whether a dropped event is still being written — false wherever moving is off. + * The drag overlays hold a landed block on its target for this window. + */ +@Composable +fun moveInFlight(): Boolean { + val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT + return flow.collectAsStateWithLifecycle().value +} + +/** + * Runs [onUndo] when an undo write begins, and never for one that began before + * this composable came on screen. + */ +@Composable +fun OnUndoStarted(onUndo: () -> Unit) { + val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE + val tick by flow.collectAsStateWithLifecycle() + var seen by remember { mutableIntStateOf(tick) } + LaunchedEffect(tick) { + if (tick == seen) return@LaunchedEffect + seen = tick + onUndo() + } +} + +/** Opacity the source block keeps while its floating copy travels. */ +const val GHOST_ALPHA: Float = 0.3f + +/** + * Opacity for a block whose copy is in flight: ghosted from the lift until the + * copy is handed back, then animated up rather than switched, so the block + * appears to travel to its new slot instead of vanishing and reappearing. + */ +@Composable +fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState( + targetValue = if (lifted) GHOST_ALPHA else 1f, + label = "ghost-alpha", +).value + +/** + * A TalkBack action that opens [event] in the edit form, so rescheduling isn't + * pointer-only. Null when this event can't be moved. + */ +@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/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt new file mode 100644 index 0000000..2a4beec --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -0,0 +1,119 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.floret.locale.currentLocale + +/** Width of the hour gutter down the start edge of the day and week timelines. */ +val GUTTER_WIDTH = 48.dp + +/** + * Start inset for the gutter's content (week badge + hour labels) so it centres + * on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp, + * matching the icon button's centre. + */ +val GUTTER_CONTENT_START_INSET = 8.dp + +private val BADGE_HEIGHT = 20.dp + +/** How far the fixed hour labels recede while a block is being dragged. */ +private const val DIMMED_HOUR_ALPHA = 0.3f + +/** + * The timeline's hour gutter. Scrolls in sync with the day columns through the + * shared [scrollState], and while [dragController] holds a lifted block it dims + * the hour labels and floats a badge with the drag's current start time at the + * row the block would land on. + */ +@Composable +fun HourGutter( + scrollState: ScrollState, + hourHeight: Dp, + dragController: TimelineDragController, + modifier: Modifier = Modifier, +) { + val use24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + // Derived: the drag is rewritten every frame, its snapped start once a slot. + val dragStartMin by remember(dragController) { + derivedStateOf { dragController.drag?.startMin } + } + val hourAlpha by animateFloatAsState( + targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f, + label = "hourLabelAlpha", + ) + + Box( + modifier = modifier + .width(GUTTER_WIDTH) + .padding(start = GUTTER_CONTENT_START_INSET) + .fillMaxHeight() + .verticalScroll(scrollState), + ) { + Column { + (0 until 24).forEach { h -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(hourHeight), + ) { + if (h > 0) { + Text( + text = formatHourLabel(h, use24Hour, locale), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = hourAlpha), + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-6).dp), + ) + } + } + } + } + dragStartMin?.let { startMin -> + val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp) + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = top) + .height(BADGE_HEIGHT) + .background(MaterialTheme.colorScheme.primary, CircleShape) + .padding(horizontal = 4.dp) + // The dragged block already announces this time. + .clearAndSetSemantics { }, + contentAlignment = Alignment.Center, + ) { + Text( + text = formatGutterTime(startMin, use24Hour, locale), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + maxLines = 1, + softWrap = false, + ) + } + } + } +} 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..1c8d95f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurringScopeDialog.kt @@ -0,0 +1,69 @@ +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. Shared by the edit + * screen's save and a drag-and-drop reschedule; one of the two carve-outs from + * the full-screen picker rule. + * + * [allowOccurrence] drops "only this event" (an exception row can't carry its own + * rule); [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..21d6257 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -0,0 +1,463 @@ +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.EventDetail +import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.EventFormProblem +import de.jeanlucmakiola.calendula.domain.RecurrenceEnd +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.allowsEventMove +import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence +import de.jeanlucmakiola.calendula.domain.problems +import de.jeanlucmakiola.calendula.domain.realignRecurrence +import de.jeanlucmakiola.calendula.domain.resolvedZone +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.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Instant +import javax.inject.Inject + +private const val MILLIS_PER_MINUTE = 60_000 +private const val MINUTES_PER_DAY = 24 * 60 + +/** 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 whole-day shift, keeping the time of day — a month-grid or all-day drag. + * A delta rather than a target date, so the screen and this view model can't + * disagree by a day over which zone the event's first day is resolved in. + */ + data class ByDays(val days: Int) : MoveTarget +} + +/** + * One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's* + * own times (`Instances.BEGIN`/`END`), as the detail and edit screens pass them. + */ +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 a wider write would leave the rule and the series + * anchor disagreeing — either the rule names days one moved occurrence can't + * re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor + * across the same midnights the occurrence crossed. + */ +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 — a stripped one would wipe the + * reminders and attendees the occurrence-exception path reconciles. + */ +@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 + + private val _inFlight = MutableStateFlow(false) + + /** + * True from the moment a drop is accepted until its write settles — the + * window the dropped block holds its landing position for. + */ + val inFlight: StateFlow = _inFlight.asStateFlow() + + private val _undoStarted = MutableStateFlow(0) + + /** + * Ticks the moment an undo write begins, before the provider has anything to + * re-read, so the view that drew the drop can carry its chip back rather than + * let it reappear on the old day. + */ + val undoStarted: StateFlow = _undoStarted.asStateFlow() + + /** + * Set from the moment a drop is accepted until its write settles. Two drops + * of the same recurring event inside that window would compute their shift + * from the same pre-move occurrence, and the shifts would compound. + */ + private var busy = false + set(value) { + field = value + _inFlight.value = value + } + + /** + * The calendars whose events may be dragged. Nothing below the UI guards + * this, 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. */ + val canRealign: Boolean, + /** The series row's DTSTART date after the move — what its `UNTIL` must clear. */ + val newAnchorDate: LocalDate, + ) + + /** + * Take a drop, unless one is already being written. False means nothing will + * be written and the caller must let its held copy go now — waiting on + * [inFlight] would strand it until the settle timeout instead. + */ + fun move(request: MoveRequest): Boolean { + if (busy || _scopePrompt.value != null) return false + busy = true + viewModelScope.launch { + val prepared = prepare(request) + if (prepared == null) { + busy = false + return@launch + } + if (!prepared.isRecurring) { + write(prepared, RecurringWriteScope.AllEvents) + busy = false + } else { + // Still busy: the scope dialog is now the thing in flight. + pending = prepared + _scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign) + } + } + return true + } + + /** Answer the scope dialog. */ + fun moveWithScope(scope: RecurringWriteScope) { + val prepared = pending ?: return + // Guard against the dialog ever offering a scope the rule can't carry. + if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return + pending = null + _scopePrompt.value = null + viewModelScope.launch { + write(prepared, scope) + busy = false + } + } + + /** Dismiss the scope dialog without writing. */ + fun cancelScope() { + pending = null + busy = false + _scopePrompt.value = null + } + + /** + * Put a completed move back where it came from. The outcome stands until the + * inverse write reports back, so the one chip changes what it says rather + * than closing and reopening. False when another write is already running — + * the chip hides its action for that window, so this is the backstop. + */ + fun undo(undo: MoveUndo): Boolean { + if (busy) return false + busy = true + _undoStarted.value += 1 + 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 + } + busy = false + } + return true + } + + /** 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.ByDays -> original.shiftedByDays(target.days, zone) + } + // A zero-distance drop is not a write. + if (shifted == original) return null + + // The UNTIL check is deferred to the write, which knows how far the move + // reaches and so which date has to clear it. + if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).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 + // The series anchor moves by the same *wall-clock* shift as the dragged + // occurrence, so only a whole-day shift carries it across the same number + // of midnights whatever time of day it sits at. A same-date drag has its + // own, narrower check — see anchorKeepsItsDay. + val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time + val realigned = if (isRecurring && movedDay) { + if (wholeDayShift) { + realignRecurrence( + requireNotNull(original.rrule), + original.start.date, + shifted.start.date, + ) + } else { + null + } + } 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 = when { + !isRecurring -> true + movedDay -> realigned != null + else -> anchorKeepsItsDay(detail, original, shifted, zone) + }, + // Where the series row's own DTSTART lands, given the anchor moves by + // the same shift. Only read under canRealign, which is what + // guarantees the anchor really does travel this many days. + newAnchorDate = anchorDate(detail, original, zone) + .plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY), + ) + } + + private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) { + val request = prepared.request + if (endsBeforeItStarts(prepared, scope)) { + _outcome.value = MoveOutcome.BlockedSeriesEnd + return + } + _outcome.value = try { + when { + !prepared.isRecurring || scope == RecurringWriteScope.AllEvents -> + 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 + } + } + + /** + * Whether this write would leave a rule whose `UNTIL` precedes the first day + * it now applies to — the provider then generates nothing at all. Which date + * has to clear `UNTIL` depends on the scope: a whole-series move carries the + * series *anchor*, a split starts at the moved occurrence, and a single + * occurrence becomes an exception row that no `UNTIL` constrains. + */ + private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean { + if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false + val rule = prepared.updated.rrule ?: return false + val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false + val firstDay = if (scope == RecurringWriteScope.AllEvents) { + prepared.newAnchorDate + } else { + prepared.updated.start.date + } + return end.date < firstDay + } + + /** + * Whether a same-date drag leaves the series anchor on its own day too. The + * anchor moves by the same wall-clock delta as the occurrence, and normally + * shares its time of day — but a row with no `EVENT_TIMEZONE` resolves the + * two in zones that can sit a DST hour apart, so a near-midnight drag could + * carry the anchor across a midnight the occurrence never crossed and leave + * `BYDAY` naming the wrong weekday. + */ + private fun anchorKeepsItsDay( + detail: EventDetail, + original: EventForm, + shifted: EventForm, + zone: TimeZone, + ): Boolean { + val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone) + val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone) + .time.toMillisecondOfDay() / MILLIS_PER_MINUTE + val delta = ( + shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay() + ) / MILLIS_PER_MINUTE + return anchorMinute + delta in 0 until MINUTES_PER_DAY + } + + /** + * The series row's own start date — for a recurring master, `EventDetail` + * carries the row's DTSTART rather than the tapped occurrence's. Read in the + * same anchoring the write path uses: UTC for an all-day series. + */ + private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate = + detail.instance.start + .toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)) + .date + + /** + * Undo is offered only where the inverse is one symmetric write: a + * non-recurring event, and a whole-series move. "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: UTC midnight for an all-day event. + */ + 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() + } + } + +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt index 362efb1..fb6262a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt @@ -17,6 +17,7 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true } private const val PATTERN_24 = "HH:mm" private const val PATTERN_12 = "h:mm a" private const val HOUR_PATTERN_12 = "h a" +private const val HOUR_MINUTE_PATTERN_12 = "h:mm" /** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */ fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter = @@ -42,6 +43,18 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String = else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale) } +/** + * The time shown in the timeline gutter while a block is dragged: 24h → + * "09:15", 12h → "9:15". The meridiem is dropped — the hour labels around it + * already carry it, and the gutter is too narrow. + */ +fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String { + val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1) + val pattern = if (is24Hour) PATTERN_24 else HOUR_MINUTE_PATTERN_12 + return LocalTime.of(clamped / 60, clamped % 60) + .format(DateTimeFormatter.ofPattern(pattern, locale)) +} + /** * The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded, * the prior look); 12h → "1 PM". 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..9420cdd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -0,0 +1,484 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +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.absoluteOffset +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.semantics.clearAndSetSemantics +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.coroutines.delay +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. Plain fields rather + * than snapshot state: it changes on every scroll frame, and the drag loop reads + * it per frame anyway. + */ +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() + + /** + * Whether the columns are laid out right-to-left. Pointer coordinates are + * never mirrored but the grid is, so the mapping has to flip with it. + */ + var isRtl: Boolean = false +} + +/** + * Hoisted drag state for one timeline (#68). It lives above the per-page + * `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the + * block it renders is drawn in an overlay, clear of the day column's clip. + */ +@Stable +class TimelineDragController { + val geometry = TimelineGeometry() + + var drag: TimelineDrag? by mutableStateOf(null) + private set + + /** + * A dropped block, held at the slot it landed on while the write runs — the + * grid behind it still shows the old time until the query re-reads. + */ + var settling: TimelineDrag? by mutableStateOf(null) + private set + + /** + * Which block is lifted, and whether anything is. Separate snapshot state + * from [drag], which changes every frame, so blocks that only need "am I the + * ghost" don't recompose that often. Stays set through [settling]. + */ + var liftedInstanceId: Long? by mutableStateOf(null) + private set + + /** + * Where the settled drop came from, and which event row it belongs to. The + * instance id alone can't identify the source block for the length of the + * write: the provider regenerates `Instances` rows, so a re-read carrying the + * *old* time can arrive under a new instance id. + */ + private var settledOrigin: Triple? by mutableStateOf(null) + + /** Whether a finger is on a block right now — [settling] is not dragging. */ + var isDragging: Boolean by mutableStateOf(false) + private set + + /** + * Whether the grid itself now draws the settled drop at its landing slot. + * The copy may only be handed back once this is true, or the source ghost + * flashes back to full opacity in its old slot. + */ + var settledOnGrid: Boolean by mutableStateOf(false) + private set + + private var source: TimedBlock? = null + private var grab = Offset.Zero + private var pointer = Offset.Zero + + /** + * The slot the block already occupied when it was picked up — not the same + * as its start, since the target snaps to the grid (09:07 lifts to 09:00). + */ + private var originSlot: Pair? = null + + fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) { + source = block + settling = null + isDragging = true + liftedInstanceId = block.event.instanceId + grab = pointerInRoot - blockInRoot + pointer = pointerInRoot + originSlot = null + recompute() + originSlot = drag?.slot + } + + fun move(pointerInRoot: Offset) { + pointer = pointerInRoot + recompute() + } + + fun cancel() { + source = null + isDragging = false + liftedInstanceId = null + originSlot = null + drag = null + settling = null + settledOnGrid = false + settledOrigin = null + } + + /** + * Whether [block] is the one whose copy is in flight, and so must stay a + * ghost: the instance the finger picked up, or once dropped whatever now + * sits in the slot it left. + */ + fun ghosts(block: TimedBlock, date: LocalDate): Boolean { + if (liftedInstanceId == null) return false + if (block.event.instanceId == liftedInstanceId) return true + val (eventId, originDate, originMin) = settledOrigin ?: return false + return block.event.eventId == eventId && + date == originDate && + block.startMin == originMin + } + + /** + * What a day column now holds, so a settled drop can tell when the grid has + * caught up with it. Matched on the landing slot plus either the event row + * or its title, since a single-occurrence move writes a new `eventId`. + */ + fun noteGrid(date: LocalDate, blocks: List) { + val landed = settling ?: return + if (settledOnGrid || landed.date != date) return + settledOnGrid = blocks.any { block -> + block.startMin == landed.startMin && + ( + block.event.eventId == landed.event.eventId || + block.event.title == landed.event.title + ) + } + } + + /** + * End the drag, handing back where it landed — null when it never resolved, + * or when it landed back on the slot it started from. A real drop keeps its + * copy on the target as [settling] until [release]. + */ + fun finish(): TimelineDrop? { + val landed = drag + val origin = originSlot + cancel() + if (landed == null || landed.slot == origin) return null + settling = landed + liftedInstanceId = landed.event.instanceId + settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) } + return TimelineDrop(landed.event, landed.date, landed.startMin) + } + + /** + * Stop ghosting the source — the grid draws the drop itself by now — while + * the copy is still dissolving. Un-ghosting at [release] instead would leave + * the block dim under the fading copy and then brighten it: a visible dip. + */ + fun handOver() { + liftedInstanceId = null + settledOrigin = null + } + + /** Drop the copy, once it has faded into the grid's own block. */ + fun release() { + settling = null + settledOnGrid = false + handOver() + } + + /** + * 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 past midnight is fine. + val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) + // The event's own length, not the block's: TimedBlock.endMin is clipped + // at midnight, which would draw a 22:00–02:00 event as a two-hour copy. + val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0) + // The column the finger is over on screen, and the day that column shows. + val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) + val dayIndex = if (geometry.isRtl) days.lastIndex - column else column + val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx) + drag = TimelineDrag( + event = block.event, + date = days[dayIndex], + 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, since a loop of + * `animateScrollBy` would re-acquire the mutex and cancel 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) + +/** + * A beat after the grid draws the drop, so its own block is under way before the + * copy starts dissolving. + */ +const val SETTLE_GRACE_MILLIS: Long = 60L + +/** + * How long to wait for a grid that never confirms the drop — it landed on a day + * this timeline doesn't show, or the write failed. The copy has to go either way. + */ +private const val SETTLE_TIMEOUT_MILLIS = 900L + +/** + * The hand-over: the copy dissolves over this while the grid's own block slides + * in under it. Kept short, because a full-width copy sits over any neighbour it + * now shares a lane with until it is gone. + */ +const val SETTLE_FADE_MILLIS: Int = 250 + +/** + * 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 + val moveInFlight = moveInFlight() + + // 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() + } + // Hold the landed copy until the write is done — including the time a + // recurring drop's scope dialog is up — and then until the grid draws the + // drop itself. + var handingOver by remember(controller.settling) { mutableStateOf(false) } + LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) { + if (controller.settling == null || moveInFlight) return@LaunchedEffect + delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS) + handingOver = true + controller.handOver() + delay(SETTLE_FADE_MILLIS.toLong()) + controller.release() + } + + Box( + modifier = modifier + .fillMaxSize() + // A copy of a block still in the tree behind it; announcing it again + // would duplicate the event for the drag's duration. + .clearAndSetSemantics { } + .onGloballyPositioned { origin = it.positionInRoot() }, + ) { + val drag = controller.drag ?: controller.settling ?: return@Box + // Landed: the copy sinks back to the grid's own plane while the write runs. + val landed = controller.drag == null + val lift by animateFloatAsState( + targetValue = if (landed || reduceMotion) 0f else 1f, + label = "drag-lift", + ) + val copyAlpha by animateFloatAsState( + targetValue = if (handingOver) 0f else 1f, + animationSpec = tween(SETTLE_FADE_MILLIS), + label = "drag-handover", + ) + val fill = eventFill(drag.event.color, dark, soften) + val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } + // An end past midnight wraps rather than saturating, so a four-hour + // event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00". + val endMin = if (drag.endMin > MINUTES_PER_DAY) { + drag.endMin % MINUTES_PER_DAY + } else { + drag.endMin + } + val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" + + formatMinuteOfDay(endMin, use24Hour, locale) + Box( + modifier = Modifier + // Absolute: these are root coordinates, and the direction-aware + // offset would mirror them across the screen in an RTL layout. + .absoluteOffset { + IntOffset( + (drag.topLeftInRoot.x - origin.x).roundToInt(), + (drag.topLeftInRoot.y - origin.y).roundToInt(), + ) + } + .size( + width = with(density) { drag.sizePx.width.toDp() }, + height = with(density) { drag.sizePx.height.toDp() }, + ) + .padding(horizontal = 1.dp) + .graphicsLayer { + scaleX = 1f + 0.02f * lift + scaleY = 1f + 0.02f * lift + shadowElevation = 8.dp.toPx() * lift + alpha = copyAlpha + 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/common/TimelineZoom.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt index f738bdf..a336a1b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt @@ -41,7 +41,14 @@ class TimelineZoom( var scale: TimelineScale by mutableStateOf(initial) private set - private var pinching = false + /** + * Whether fingers are rescaling the timeline right now. Snapshot state, so + * anything that tweens off [scale] can stand down for the gesture: the + * height changes every pointer frame, and a spring would spend the whole + * pinch chasing a target that has already moved. + */ + var isPinching: Boolean by mutableStateOf(false) + private set /** * Take a value that came from the preference. Ignored mid-pinch: the stored @@ -49,11 +56,11 @@ class TimelineZoom( * snap the timeline back while the user is still pinching. */ fun adopt(stored: TimelineScale) { - if (!pinching) scale = stored + if (!isPinching) scale = stored } fun beginPinch() { - pinching = true + isPinching = true } fun pinchTo(hourHeight: Dp) { @@ -61,7 +68,7 @@ class TimelineZoom( } fun endPinch() { - pinching = false + isPinching = false persist(scale) } } 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..b6ccc56 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 @@ -42,6 +42,7 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -53,14 +54,19 @@ 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.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -74,7 +80,21 @@ 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.BlockTimeLabel +import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement +import de.jeanlucmakiola.calendula.ui.common.ghostAlpha +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 @@ -93,8 +113,9 @@ import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom -import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay +import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH +import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.week.TimedBlock import kotlinx.coroutines.flow.first @@ -106,11 +127,6 @@ import kotlin.time.Clock import java.util.Locale import kotlin.math.roundToInt -private val GUTTER_WIDTH = 48.dp -/** Start inset for the gutter's hour labels so they centre on the top bar's - * hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's - * 4dp inset + 24dp half icon button), matching the week view. */ -private val GUTTER_CONTENT_START_INSET = 8.dp private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp @@ -298,31 +314,54 @@ 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 -> + val took = move?.move( + MoveRequest( + eventId = drop.event.eventId, + beginMillis = drop.event.start.toEpochMilliseconds(), + endMillis = drop.event.end.toEpochMilliseconds(), + target = MoveTarget.Start(drop.startInstant(zone)), + ), + ) + // Refused, so nothing will land: let the copy go now + // rather than hold it out for a settle that never comes. + if (took != true) dragController.release() + }, + ) } - }, - 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 +371,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 +393,10 @@ private fun DaySuccess( Timeline( state = state, scrollState = scrollState, + dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, + onDrop = onDrop, ) } } @@ -487,13 +530,15 @@ 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 + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top @@ -512,39 +557,19 @@ private fun Timeline( Row(modifier = Modifier.fillMaxSize().then(pinch)) { // Hour gutter (scrolls in sync with the day column). Start inset so the // labels centre on the top bar hamburger, matching the week view. - Column( - modifier = Modifier - .width(GUTTER_WIDTH) - .padding(start = GUTTER_CONTENT_START_INSET) - .fillMaxHeight() - .verticalScroll(scrollState), - ) { - (0 until 24).forEach { h -> - Box( - modifier = Modifier - .fillMaxWidth() - .height(hourHeight), - ) { - if (h > 0) { - Text( - text = formatHourLabel(h, use24Hour, locale), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .align(Alignment.TopCenter) - .offset(y = (-6).dp), - ) - } - } - } - } + HourGutter( + scrollState = scrollState, + hourHeight = hourHeight, + dragController = dragController, + ) // Day column: rounded, clipped scroll viewport (permanent corners). Box( modifier = Modifier .weight(1f) .fillMaxHeight() .clip(RoundedCornerShape(16.dp)) - .verticalScroll(scrollState), + .verticalScroll(scrollState) + .onGloballyPositioned { dragController.geometry.viewport = it }, ) { DayColumnCard( blocks = state.timed, @@ -552,11 +577,26 @@ 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) + it.isRtl = isRtl + } + }, ) } } @@ -570,13 +610,19 @@ 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() } val showHourLines = LocalShowHourLines.current val hourLineColor = MaterialTheme.colorScheme.outlineVariant + // Tells a settled drop when this column has caught up with it. + LaunchedEffect(blocks, dragController.settling) { + dragController.noteGrid(date, blocks) + } Card( // Plain rectangular column — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -604,22 +650,38 @@ private fun DayColumnCard( ) { val colWidth = maxWidth val minEventHeight = hourHeight * MIN_EVENT_FRACTION + // Keyed by event, so a block that changes time or lane is the *same* + // composable afterwards and tweens there. The ordinal disambiguates + // a column holding two occurrences of one series. + val ordinals = mutableMapOf() blocks.forEach { block -> - val laneWidth = colWidth / block.laneCount - val top = hourHeight * (block.startMin / 60f) - val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight - EventBlock( - block = block, - dark = dark, - height = height, - onClick = { onEventClick(block.event) }, - modifier = Modifier - .offset(x = laneWidth * block.lane, y = top) - .width(laneWidth) - .height(height) - .padding(horizontal = 1.dp), - ) + val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 + key(block.event.eventId, ordinal) { + val laneWidth = colWidth / block.laneCount + val top = hourHeight * (block.startMin / 60f) + val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) + val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight + val place = animatedBlockPlacement( + x = laneWidth * block.lane, + y = top, + width = laneWidth, + height = height, + ) + EventBlock( + block = block, + dark = dark, + height = place.height, + date = date, + dragController = dragController, + onClick = { onEventClick(block.event) }, + onDrop = onDrop, + modifier = Modifier + .offset(x = place.x, y = place.y) + .width(place.width) + .height(place.height) + .padding(horizontal = 1.dp), + ) + } } // Current-time line, on top of the events, only on today's column. if (date == today) { @@ -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,35 @@ 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 draggable = moveAction != null && block.beginsOn(date, zone) + val dragModifier = rememberEventDragSource( + enabled = draggable, + key = block.event.instanceId, + onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, + onMove = dragController::move, + onDrop = { dragController.finish()?.let(onDrop) }, + onCancel = dragController::cancel, + ) + val lifted = draggable && dragController.ghosts(block, date) + val ghost = ghostAlpha(lifted) Box( modifier = modifier + // The source stays put as a ghost while its floating copy travels. + .then(if (ghost < 1f) Modifier.alpha(ghost) 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) { @@ -676,11 +764,8 @@ private fun EventBlock( ) } if (showTime) { - Text( - text = timeLabel, - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + BlockTimeLabel( + label = timeLabel, color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), ) } 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..f3dcf3c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt @@ -0,0 +1,303 @@ +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.layout.positionInRoot +import androidx.compose.ui.unit.IntSize +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import kotlin.math.abs + +/** 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, + /** The lane band inside that box, where the chips themselves are seated. */ + val band: LayoutCoordinates?, + val columnWidthPx: Float, + val laneHeightPx: Float, + val laneCount: Int, + /** + * Whether the columns are laid out right-to-left. Pointer coordinates are + * never mirrored but the grid is, so the mapping has to flip with it. + */ + val isRtl: Boolean, + /** What this row currently draws in [lane] of column `col`, or null. */ + val chipAt: (col: Int, lane: Int) -> EventInstance?, +) { + /** Root top-left of the chip seated in [lane] of column [col]. */ + fun seat(col: Int, lane: Int): Offset? { + val band = band?.takeIf { it.isAttached } ?: return null + val origin = band.positionInRoot() + val column = if (isRtl) days.lastIndex - col else col + return Offset(origin.x + column * columnWidthPx, origin.y + lane * laneHeightPx) + } + + /** + * Where this row seats [event] on [date], or null when it doesn't hold it — + * the day isn't in this week, or the chip went into the day's "+N" overflow. + */ + fun seatOf(event: EventInstance, date: LocalDate): Offset? { + val col = days.indexOf(date).takeIf { it >= 0 } ?: return null + val lane = (0 until laneCount).firstOrNull { lane -> + chipAt(col, lane)?.let { isSameEvent(it, event) } == true + } ?: return null + return seat(col, lane) + } +} + +/** + * Whether [chip] is the moved event [moved] as the grid now holds it. Neither id + * alone will do: re-read instances get new instance ids, and a single-occurrence + * move writes a new event id — the title survives both. + */ +private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean = + chip.eventId == moved.eventId || chip.title == moved.title + +/** 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 target resolved against + * whichever registered row the finger is over. Rows are keyed by an identity + * token rather than a date: the continuous style can show the same week twice. + */ +@Stable +class MonthDragController { + private val rows = LinkedHashMap() + + var drag: MonthChipDrag? by mutableStateOf(null) + private set + + /** + * A dropped chip, held on the day it landed on while the write runs — the + * grid behind it still shows the old day until the query re-reads. + */ + var settling: MonthChipDrag? by mutableStateOf(null) + private set + + /** + * Which chip is lifted, and whether anything is. Separate snapshot state from + * [drag], which changes every frame, so the chips and rows that only need + * "am I the ghost" don't recompose that often. Stays set through [settling]. + */ + var liftedInstanceId: Long? by mutableStateOf(null) + private set + + /** + * Where the grid itself now draws the drop, in root coordinates — published + * by whichever week row ends up holding it. The copy is dropped wherever the + * finger was while the grid seats it in a lane, so without this it would + * cross-fade across the gap instead of gliding onto its seat. + */ + var settledInRoot: Offset? by mutableStateOf(null) + private set + + /** + * Whether the drop's own chip is still standing in for the copy. Cleared at + * [handOver] rather than [release], or the chip stays dim under a copy that + * has already faded and brightens afterwards: a visible dip. + */ + private var settledGhost: Boolean by mutableStateOf(false) + + /** Whether a finger is on a chip right now — [settling] is not dragging. */ + var isDragging: Boolean by mutableStateOf(false) + private set + + /** + * The last drop this controller made, kept past [release] for [beginUndo] — + * the confirmation chip carries Undo long after the copy is gone. + */ + private var undoable: MonthChipDrag? = null + + 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 + + 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 + settling = null + isDragging = true + liftedInstanceId = event.instanceId + grab = pointerInRoot - chipInRoot + pointer = pointerInRoot + sizePx = size + recompute() + } + + fun move(pointerInRoot: Offset) { + pointer = pointerInRoot + recompute() + } + + fun cancel() { + event = null + grabDate = null + isDragging = false + liftedInstanceId = null + drag = null + settling = null + settledInRoot = null + settledGhost = false + } + + /** + * Whether [days] holds the chip the grid has drawn for the settled drop — + * the seat the copy is on its way to, which must ghost until it gets there. + */ + fun isSettledChip(event: EventInstance, days: List?): Boolean { + val landed = settling?.takeIf { settledGhost } ?: return false + if (days == null || landed.targetDate !in days) return false + return isSameEvent(event, landed.event) + } + + /** + * Ask the row keyed [token] whether it now seats the settled chip. Both + * copies of a week the continuous style shows twice will answer; the seat + * nearest where the chip was let go is the one the finger was over. + */ + fun noteSettled(token: Any) { + val landed = settling ?: return + val target = landed.targetDate ?: return + val at = rows[token]?.seatOf(landed.event, target) ?: return + val current = settledInRoot + val closer = current == null || + abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y) + if (closer) settledInRoot = at + } + + /** + * Put a chip on the journey an undo's inverse write is about to make, so it + * travels back rather than teleporting. Writes nothing itself. False when + * there is no drop of this controller's left to undo, or the grid doesn't + * seat the moved event where the chip would have to start from. + */ + fun beginUndo(): Boolean { + val last = undoable ?: return false + undoable = null + val from = last.targetDate ?: return false + val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false + settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at) + liftedInstanceId = last.event.instanceId + settledGhost = true + settledInRoot = null + return true + } + + /** + * End the drag, handing back where it landed — null when it never resolved, + * or when it landed back on the day it came from. A real drop keeps its chip + * on the target day as [settling] until [release], slid over to that day's + * column so it lands where the grid is about to draw it. + */ + fun finish(): MonthChipDrop? { + val landed = drag + val left = landed?.targetDate?.let(::columnLeft) + cancel() + val target = landed?.targetDate?.takeIf { it != landed.grabDate } ?: return null + settling = landed.copy( + topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y), + ) + liftedInstanceId = landed.event.instanceId + settledGhost = true + undoable = settling + return MonthChipDrop(landed.event, landed.grabDate, target) + } + + /** + * Stop ghosting the source while the copy is still dissolving — un-ghosting + * at [release] instead leaves the chip dim and then brightens it. + */ + fun handOver() { + liftedInstanceId = null + settledGhost = false + } + + /** Drop the copy, once it has faded into the grid's own chip. */ + fun release() { + settling = null + settledInRoot = null + handOver() + } + + /** Root x of [date]'s column, in whichever visible row shows that day. */ + private fun columnLeft(date: LocalDate): Float? = rows.values.firstNotNullOfOrNull { row -> + val index = row.days.indexOf(date).takeIf { it >= 0 } ?: return@firstNotNullOfOrNull null + val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot() + ?: return@firstNotNullOfOrNull null + val column = if (row.isRtl) row.days.lastIndex - index else index + bounds.left + column * row.columnWidthPx + } + + 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[if (row.isRtl) row.days.lastIndex - column else column] + } + } + drag = MonthChipDrag( + event = event, + grabDate = grabbed, + // Off the grid entirely: 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..14c7628 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 @@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.animateFloat @@ -59,9 +61,11 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect 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 @@ -77,7 +81,32 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.geometry.Offset +import kotlin.math.roundToInt +import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface +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.moveInFlight +import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted +import de.jeanlucmakiola.calendula.ui.common.ghostAlpha +import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics +import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS +import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS +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 @@ -126,6 +155,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch @@ -384,13 +414,23 @@ fun MonthScreen( ) }, ) { innerPadding -> - Column( + // Hoisted above every style's grid: a dragged chip is drawn in an + // overlay, clear of the week row's and the list viewport's clips. + val chipDrag = rememberMonthDragController() + // Undo moves the event back, so the chip travels back too. The + // signal comes from the write, not from the chip that offers it. + OnUndoStarted { chipDrag.beginUndo() } + 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 +467,103 @@ fun MonthScreen( } } } + MonthDragOverlay(chipDrag) + } } } } +/** + * How long to wait for a grid that never seats the drop — it landed in a day's + * "+N" overflow or on a day this month doesn't show, or the write failed. + */ +private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L + +/** 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() + val moveInFlight = moveInFlight() + // 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) + // Hold the landed chip until the write is done — including the time a + // recurring drop's scope dialog is up — and then, unlike the timeline, carry + // it to its seat: it was dropped at whatever height the finger was at, while + // the grid seats it in a lane. + var handingOver by remember(controller.settling) { mutableStateOf(false) } + var gliding by remember(controller.settling) { mutableStateOf(false) } + val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) } + val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec() + val seat = controller.settledInRoot + LaunchedEffect(controller.settling, moveInFlight, seat) { + val landed = controller.settling + if (landed == null || moveInFlight) return@LaunchedEffect + if (seat == null) { + delay(MONTH_SETTLE_TIMEOUT_MILLIS) + } else { + if (!gliding) glide.snapTo(landed.topLeftInRoot) + gliding = true + if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec) + delay(SETTLE_GRACE_MILLIS) + } + handingOver = true + controller.handOver() + delay(SETTLE_FADE_MILLIS.toLong()) + controller.release() + } + Box( + modifier = Modifier + .fillMaxSize() + // A copy of a chip still in the tree behind it; announcing it again + // would duplicate the event for the drag's duration. + .clearAndSetSemantics { } + .onGloballyPositioned { origin = it.positionInRoot() }, + ) { + val drag = controller.drag ?: controller.settling ?: return@Box + // Landed: the copy sinks back to the grid's own plane while the write runs. + val lift by animateFloatAsState( + targetValue = if (controller.drag == null || reduceMotion) 0f else 1f, + label = "chip-lift", + ) + val copyAlpha by animateFloatAsState( + targetValue = if (handingOver) 0f else 1f, + animationSpec = tween(SETTLE_FADE_MILLIS), + label = "chip-handover", + ) + MonthBar( + event = drag.event, + dark = dark, + continuesLeft = false, + continuesRight = false, + modifier = Modifier + // Absolute: these are root coordinates, and the direction-aware + // offset would mirror them across the screen in an RTL layout. + .absoluteOffset { + val at = if (gliding) glide.value else drag.topLeftInRoot + IntOffset( + (at.x - origin.x).roundToInt(), + (at.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 { + scaleX = 1f + 0.04f * lift + scaleY = 1f + 0.04f * lift + shadowElevation = 8.dp.toPx() * lift + alpha = copyAlpha + shape = RoundedCornerShape(4.dp) + clip = false + }, + ) + } +} + @Composable private fun MonthContent( state: MonthUiState, @@ -1720,6 +1853,50 @@ 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 sits on top of them — 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 cellCoordinates = remember { arrayOfNulls(1) } + val density = LocalDensity.current + val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() } + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val dragging = dragController?.isDragging == true + DisposableEffect(rowToken, dragController) { + onDispose { dragController?.removeRow(rowToken) } + } + // Republished on layout *and* on every recomposition: the coordinates change + // only on the former, but what the row draws — which the controller reads to + // find a moved chip's seat — changes on the latter. + val publish = { + val cell = cellCoordinates[0]?.takeIf { it.isAttached } + if (dragController != null && cell != null) { + dragController.putRow( + rowToken, + MonthRowGeometry( + days = week.days, + cell = cell, + band = bandCoordinates[0], + columnWidthPx = cell.size.width / 7f, + laneHeightPx = rowHeightPx, + laneCount = MAX_EVENT_ROWS, + isRtl = isRtl, + chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) }, + ), + ) + } + } + SideEffect { publish() } + // Once the grid holds the settled chip, tell the controller where this row + // has seated it, so the copy in flight can glide onto it. Keyed on the week, + // which is what changes when the re-read lands. + LaunchedEffect(week, dragController?.settling) { + dragController?.noteSettled(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 +1912,21 @@ private fun MonthWeekRow( BoxWithConstraints( Modifier .weight(1f) - .fillMaxHeight(), + .fillMaxHeight() + .onGloballyPositioned { coords -> + cellCoordinates[0] = coords + publish() + } + .then( + monthChipDragModifier( + week = week, + moveScope = moveScope, + controller = dragController, + band = bandCoordinates, + rowHeightPx = rowHeightPx, + isRtl = isRtl, + ), + ), ) { val colW = maxWidth / 7 @@ -1800,7 +1991,13 @@ private fun MonthWeekRow( modifier = Modifier .fillMaxWidth() .weight(1f) - .then(if (morphing) Modifier else Modifier.clipToBounds()), + .onGloballyPositioned { + bandCoordinates[0] = it + publish() + } + // A dragged chip travels to another row, so the clip + // yields for it 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 -> @@ -1810,6 +2007,7 @@ private fun MonthWeekRow( dark = dark, continuesLeft = span.continuesLeft, continuesRight = span.continuesRight, + days = week.days.subList(span.startCol, span.endCol + 1), modifier = Modifier .offset( x = colW * span.startCol, @@ -1879,6 +2077,7 @@ private fun MonthWeekRow( dark = dark, continuesLeft = false, continuesRight = false, + days = listOf(d), modifier = Modifier .offset( x = colW * col, @@ -1959,6 +2158,73 @@ private fun MonthWeekRow( } } +/** + * The row-level pickup for month chips: resolves which chip the press landed on + * from the geometry the row just laid out, and abandons the gesture on empty + * space (or an unmovable calendar) so tapping a day still opens it. + */ +@Composable +private fun monthChipDragModifier( + week: MonthWeek, + moveScope: EventMoveScope?, + controller: MonthDragController?, + band: Array, + rowHeightPx: Float, + isRtl: Boolean, +): 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() + // The column under the finger on screen, and the day that column shows. + val column = (local.x / columnPx).toInt() + val dayIndex = if (isRtl) week.days.lastIndex - column else column + val event = if (bandY < 0f || columnPx <= 0f) { + null + } else { + week.chipAt(dayIndex, lane, MAX_EVENT_ROWS) + } + if (event == null || moveScope?.allows(event) != true) { + false + } else { + controller?.begin( + event = event, + grabDate = week.days[dayIndex], + 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 -> + // How many columns the finger crossed — grabbing the middle of a + // multi-day bar shifts by what it travelled, not to where it landed. + val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt() + val took = moveScope?.move( + MoveRequest( + eventId = drop.event.eventId, + beginMillis = drop.event.start.toEpochMilliseconds(), + endMillis = drop.event.end.toEpochMilliseconds(), + target = MoveTarget.ByDays(delta), + ), + ) + // Refused, so nothing will land: let the copy go now rather than + // hold the chip out for a settle that can never arrive. + if (took != true) controller?.release() + } + }, + 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 @@ -2051,12 +2317,24 @@ private fun MonthBar( continuesLeft: Boolean, continuesRight: Boolean, modifier: Modifier = Modifier, + /** + * The days this chip covers in its row, for the drag (#68). A drop's own chip + * has to ghost until the copy lands on it, and the instance id can't find it: + * the provider hands the re-read instance a new one. + */ + days: List? = null, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val dimCutoff = LocalDimCutoff.current 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 monthDrag = LocalMonthDrag.current + val lifted = monthDrag?.liftedInstanceId == event.instanceId || + monthDrag?.isSettledChip(event, days) == true + val ghost = ghostAlpha(lifted) val shape = RoundedCornerShape( topStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp, @@ -2065,9 +2343,13 @@ private fun MonthBar( ) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) + .then(if (ghost < 1f) Modifier.alpha(ghost) 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..56624d4 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,23 @@ 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..8c79743 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 @@ -47,6 +47,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.key import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -62,13 +63,17 @@ 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.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -84,6 +89,20 @@ 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.BlockTimeLabel +import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement +import de.jeanlucmakiola.calendula.ui.common.ghostAlpha +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 @@ -104,8 +123,10 @@ import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom -import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay +import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET +import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH +import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next @@ -121,13 +142,10 @@ import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle import java.util.Locale -private val GUTTER_WIDTH = 48.dp -/** Start inset for the gutter's content (week badge + hour labels) so it centres - * on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp - * (the app bar's 4dp inset + 24dp half icon button). */ -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 +349,55 @@ private fun WeekContent( // gestures coexist without fighting. 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 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 -> + val took = move?.move( + MoveRequest( + eventId = drop.event.eventId, + beginMillis = drop.event.start.toEpochMilliseconds(), + endMillis = drop.event.end.toEpochMilliseconds(), + target = MoveTarget.Start(drop.startInstant(zone)), + ), + ) + // Refused, so nothing will land: let the copy go now + // rather than hold it out for a settle that never comes. + if (took != true) dragController.release() + }, + ) } - }, - 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 +407,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 +428,10 @@ private fun WeekSuccess( Timeline( state = state, scrollState = scrollState, + dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, + onDrop = onDrop, ) } } @@ -622,13 +667,15 @@ 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 + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top @@ -648,45 +695,39 @@ private fun Timeline( Row(modifier = Modifier.fillMaxSize().then(pinch)) { // Hour gutter (scrolls in sync with the day columns). Same start inset // as the header badge so the labels sit under it and on the hamburger. - Column( - modifier = Modifier - .width(GUTTER_WIDTH) - .padding(start = GUTTER_CONTENT_START_INSET) - .fillMaxHeight() - .verticalScroll(scrollState), - ) { - (0 until 24).forEach { h -> - Box( - modifier = Modifier - .fillMaxWidth() - .height(hourHeight), - ) { - if (h > 0) { - Text( - text = formatHourLabel(h, use24Hour, locale), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .align(Alignment.TopCenter) - .offset(y = (-6).dp), - ) - } - } - } - } + HourGutter( + scrollState = scrollState, + hourHeight = hourHeight, + dragController = dragController, + ) // Day columns: rounded, clipped scroll viewport (permanent corners). Box( modifier = Modifier .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. + .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 + it.isRtl = isRtl + } + }, + horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP), ) { state.days.forEach { day -> DayColumnCard( @@ -695,8 +736,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,13 +758,19 @@ 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() } val showHourLines = LocalShowHourLines.current val hourLineColor = MaterialTheme.colorScheme.outlineVariant + // Tells a settled drop when this column has caught up with it. + LaunchedEffect(blocks, dragController.settling) { + dragController.noteGrid(date, blocks) + } Card( // Plain rectangular columns — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -748,23 +797,39 @@ private fun DayColumnCard( ) { val colWidth = maxWidth val minEventHeight = hourHeight * MIN_EVENT_FRACTION + // Keyed by event, so a block that changes time or lane is the *same* + // composable afterwards and tweens there. The ordinal disambiguates + // a column holding two occurrences of one series. + val ordinals = mutableMapOf() blocks.forEach { block -> - val laneWidth = colWidth / block.laneCount - val top = hourHeight * (block.startMin / 60f) - val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight - EventBlock( - block = block, - dark = dark, - height = height, - width = laneWidth, - onClick = { onEventClick(block.event) }, - modifier = Modifier - .offset(x = laneWidth * block.lane, y = top) - .width(laneWidth) - .height(height) - .padding(horizontal = 1.dp), - ) + val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 + key(block.event.eventId, ordinal) { + val laneWidth = colWidth / block.laneCount + val top = hourHeight * (block.startMin / 60f) + val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) + val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight + val place = animatedBlockPlacement( + x = laneWidth * block.lane, + y = top, + width = laneWidth, + height = height, + ) + EventBlock( + block = block, + dark = dark, + height = place.height, + width = place.width, + date = date, + dragController = dragController, + onClick = { onEventClick(block.event) }, + onDrop = onDrop, + modifier = Modifier + .offset(x = place.x, y = place.y) + .width(place.width) + .height(place.height) + .padding(horizontal = 1.dp), + ) + } } // Current-time line, on top of the events, only on today's column. if (date == today) { @@ -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,35 @@ 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 draggable = moveAction != null && block.beginsOn(date, zone) + val dragModifier = rememberEventDragSource( + enabled = draggable, + key = block.event.instanceId, + onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, + onMove = dragController::move, + onDrop = { dragController.finish()?.let(onDrop) }, + onCancel = dragController::cancel, + ) + val lifted = draggable && dragController.ghosts(block, date) + val ghost = ghostAlpha(lifted) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) + // The source stays put as a ghost while its floating copy travels. + .then(if (ghost < 1f) Modifier.alpha(ghost) 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) { @@ -841,11 +932,8 @@ private fun EventBlock( ) } if (showTime) { - Text( - text = timeLabel, - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + BlockTimeLabel( + label = timeLabel, color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e296d4..ae5a2ec 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 + Moving the whole series would change which days it falls on, 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..e8f4a75 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,88 @@ 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, while RRULE is + // written verbatim: without realignRecurrence 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 `undoing a whole-series move lands the anchor back where it started`() { + // What makes undo safe for "all events": the shift is applied to the + // anchor the provider currently holds, so re-issuing it with the two + // forms swapped applies the exact inverse to the moved anchor. + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)), + ) + + val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long + assertThat(movedAnchor).isNotEqualTo(series) + + val restored = update(moved, original, movedAnchor) + assertThat(restored[CalendarContract.Events.DTSTART]).isEqualTo(series) + assertThat(restored[CalendarContract.Events.DURATION]).isEqualTo("P3600S") + } + @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..b557dc2 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,26 @@ 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`() { + // The predicate is deliberately not isEventTarget. + 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..53c96ca --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -0,0 +1,155 @@ +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: 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, berlin) + assertThat(moved.start.date).isEqualTo(LocalDate(2026, 6, 14)) + assertThat(moved.end.date).isEqualTo(LocalDate(2026, 6, 14)) + // The placeholder times exist only for a switch back to timed; untouched. + 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, berlin) + + assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0))) + assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0))) + } + + @Test + fun `a timed shift onto a DST changeover keeps the real length, not the wall clock`() { + // 22:00 Sat -> 04:00 Sun is six real hours, onto the Sunday Berlin + // springs forward on. Keeping wall clock would write five. + val overnight = form( + start = LocalDateTime(LocalDate(2026, 3, 21), LocalTime(22, 0)), + end = LocalDateTime(LocalDate(2026, 3, 22), LocalTime(4, 0)), + ) + + val moved = overnight.shiftedByDays(7, berlin) + + assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0))) + assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin)) + .isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin)) + // The wall-clock end therefore lands an hour later than a naive +7 days. + assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0))) + } + + @Test + fun `shifting by no days is a no-op`() { + val original = form() + assertThat(original.shiftedByDays(0, berlin)).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..5b9b590 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceRealignTest.kt @@ -0,0 +1,111 @@ +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 day-of-month rule is refused, because the anchor moves by days not dates`() { + // BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1: + // the rebuilt rule would say the 1st while the anchor became Jan 29 — a + // DTSTART that is not an instance of its own rule. + assertThat(realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday)).isNull() + assertThat( + realignRecurrence("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", monday, LocalDate(2026, 7, 20)), + ).isNull() + } + + @Test + fun `BYDAY on a non-weekly rule is refused`() { + // parseSimpleRecurrence can't read it either, so the UNTIL guard + // downstream would be blind to it. + assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO", monday, wednesday)).isNull() + } + + @Test + fun `a rule with no FREQ is refused`() { + assertThat(realignRecurrence("INTERVAL=2;BYDAY=MO", monday, wednesday)).isNull() + } + + @Test + 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=WEEKLY;BYDAY=MO;BYSETPOS=1", monday, wednesday)) + .isNull() + assertThat(realignRecurrence("FREQ=YEARLY;BYYEARDAY=159", monday, wednesday)).isNull() + } + + @Test + fun `everything realignable is also a rule the UNTIL guard can read`() { + // problems() checks UNTIL through parseSimpleRecurrence; a rule this + // realigns but that parser rejects would go unchecked. + val realignable = listOf( + "FREQ=WEEKLY;BYDAY=MO", + "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;UNTIL=20261231T225959Z", + "FREQ=DAILY;COUNT=5", + ) + realignable.forEach { rule -> + assertThat(realignRecurrence(rule, monday, wednesday)).isNotNull() + assertThat(parseSimpleRecurrence(realignRecurrence(rule, monday, wednesday)!!)) + .isNotNull() + } + } + + @Test + fun `a leading RRULE prefix is preserved`() { + assertThat(realignRecurrence("RRULE:FREQ=WEEKLY;BYDAY=MO", monday, wednesday)) + .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..c29a521 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -0,0 +1,497 @@ +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 in the device zone — the zone the drop path + // reads dates back in. The BYDAY assertions depend on it being a Monday. + private val monday = LocalDate(2026, 6, 8) + private val beginMillis = LocalDateTime(monday, LocalTime(12, 0)) + .toInstant(TimeZone.currentSystemDefault()) + .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, + anchorMillis: Long = beginMillis, + ): EventDetail = EventDetail( + instance = EventInstance( + instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup", + start = Instant.fromEpochMilliseconds(anchorMillis), + end = Instant.fromEpochMilliseconds(anchorMillis + (endMillis - beginMillis)), + 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.ByDays(2), + ) + + /** 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 `a time-only drop that would carry the anchor past midnight offers only the occurrence`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + // The anchor sits at 23:30 while the dragged occurrence is at midday — + // only possible when the row pins no zone, so the two resolve an hour + // apart. +1h leaves the occurrence on its day but rolls the anchor onto + // the next one, which would leave BYDAY naming the wrong weekday. + val anchor = LocalDateTime(LocalDate(2026, 6, 1), LocalTime(23, 30)) + .toInstant(TimeZone.currentSystemDefault()) + .toEpochMilliseconds() + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO", anchorMillis = anchor) } + } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true)) + } + + @Test + fun `a second drop is refused while the first is still in flight`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") } + } + val vm = viewModel(tempDir, fake) + + assertThat(vm.move(toWednesday())).isTrue() + advanceUntilIdle() + + // Parked on the scope dialog, so the first drop still owns the pipeline. + assertThat(vm.move(oneHourLater())).isFalse() + advanceUntilIdle() + assertThat(fake.updatedEvents).isEmpty() + } + + @Test + fun `undo is refused while a write is running`(@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!! + + // A second drop parks on its scope dialog, which holds the pipeline. + fake.eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") } + vm.move(toWednesday()) + advanceUntilIdle() + + assertThat(vm.undo(undo)).isFalse() + advanceUntilIdle() + assertThat(fake.updatedEvents).hasSize(1) + } + + @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()) + vm.moveWithScope(RecurringWriteScope.AllEvents) + advanceUntilIdle() + + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd) + assertThat(fake.updatedEvents).isEmpty() + } + + @Test + fun `dragging a bounded series' last occurrence still allows moving just it`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") } + } + val vm = viewModel(tempDir, fake) + + vm.move(toWednesday()) + advanceUntilIdle() + // An exception row is constrained by no UNTIL, so the scope stays on offer. + assertThat(vm.scopePrompt.value).isNotNull() + + vm.moveWithScope(RecurringWriteScope.ThisEvent) + advanceUntilIdle() + + assertThat(fake.updatedOccurrences).hasSize(1) + assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java) + } + + @Test + fun `a drag that changes both the day and the time cannot move the series`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") } + } + val vm = viewModel(tempDir, fake) + + // Onto the next day *and* two hours later: a late enough anchor would + // cross two midnights under the same wall-clock shift. + vm.move( + MoveRequest( + eventId = 42L, + beginMillis = beginMillis, + endMillis = endMillis, + target = MoveTarget.Start( + Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L), + ), + ), + ) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true)) + } + + @Test + fun `a zero-distance drop writes nothing and says nothing`( + @TempDir tempDir: Path, + ) = 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/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt index 4aca580..3fb84ce 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt @@ -27,6 +27,14 @@ class TimeFormatTest { assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15") } + @Test + fun `gutter time drops the meridiem and clamps to the day`() { + assertThat(formatGutterTime(9 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("09:15") + assertThat(formatGutterTime(13 * 60 + 45, is24Hour = false, Locale.US)).isEqualTo("1:45") + assertThat(formatGutterTime(0, is24Hour = false, Locale.US)).isEqualTo("12:00") + assertThat(formatGutterTime(1_440, is24Hour = true, Locale.US)).isEqualTo("23:59") + } + @Test fun `hour label is zero-padded in 24h and compact am-pm in 12h`() { assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c56ffab..5ba192b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,6 +108,83 @@ 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`, and + returns **null** for everything else — the drop then offers only *this event*, + whose exception row carries no rule at all. The same staleness is reachable + from the edit screen; wiring it there too is a separate change. + + What the rule has to agree with is the **series anchor**, not the occurrence + being dragged, and `buildEventUpdateValues` moves that anchor by the same + *wall-clock* shift. So the realigner only touches weekday, which is uniform mod + 7 and therefore survives that shift whatever time of day the anchor sits at. + Day-of-month is not uniform — `BYMONTHDAY=28` with a January anchor, occurrence + Feb 28 dragged to Mar 1, would leave a Jan 29 anchor under a `BYMONTHDAY=1` + rule, a DTSTART that is not an instance of its own rule and a phantom + occurrence on any client that trusts it. + + Uniform mod 7 is not enough on its own: the anchor also has to cross the *same + number of midnights* as the occurrence did, or the rebuilt weekday is off by a + day. `RescheduleViewModel` requires that of every write wider than one + occurrence, in the two shapes a drag comes in: + + - a drag that **changes the day** must be a whole number of days, so the two + move in step whatever the anchor's time of day; + - a **time-only** drag must leave the anchor on its own day. That is normally + free, since the anchor shares the occurrence's time of day — but a row with + no `EVENT_TIMEZONE` resolves anchor and occurrence in zones that can sit a + DST hour apart, and a near-midnight drag would then carry the anchor across a + midnight the occurrence never crossed. + + Whatever falls outside — a rule the realigner won't rebuild, or a shift that + would move rule and anchor out of step — may only move the one occurrence. + + The accepted parts are deliberately a **subset** of what `parseSimpleRecurrence` + understands, so anything realignable is also a rule whose `UNTIL` the guard + below can actually read. +- **The eligibility gate is load-bearing.** Nothing below the UI refuses a + write, so `CalendarSource.allowsEventMove` is what keeps read-only and + contact-managed events from being dragged. It is deliberately *not* + `isEventTarget`: a managed event is editable (reminders, notes) yet must never + move, while a switched-off calendar renders nothing to grab anyway. + +A drop that would push a series past its own `UNTIL` — the provider then +generates zero occurrences and the event vanishes from every view — is refused +rather than written. The check happens at **write** time, not at drop time, +because which date has to clear `UNTIL` depends on how far the write reaches: a +whole-series move carries the *anchor*, a split starts a new series at the moved +occurrence, and a single occurrence becomes an exception row that no `UNTIL` +constrains. Testing the occurrence in every case would refuse the perfectly +ordinary drag of a bounded series' last occurrence. + +**One drop is written at a time.** Two drops of the same recurring event landing +inside one write window would each compute their shift from the same pre-move +occurrence, while the data layer applies both to the re-read anchor — so the +shifts would compound. `RescheduleViewModel.move` therefore refuses while a write +(or its scope dialog) is outstanding, and says so in its return value: the view +that took the drop releases the block it was holding on the target instead of +waiting out a settle that will never arrive. + +Two known limitations of a whole-series move, both shared with the edit screen's +own *All events* time save rather than introduced here — a drag just makes them +one gesture away: the series' `EXDATE` stamps and its exception rows are **not** +re-anchored, so previously deleted occurrences can reappear and previously +modified ones stay behind while the rest of the series moves. + ### Event time zones `EventForm.timezone` is the zone its wall-clock times mean, and **null means @@ -144,6 +221,22 @@ 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 on the confirmation chip +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. Two further gaps, both narrow and +accepted: a shift whose *anchor* crosses a DST gap is not invertible in wall +clock (the −Δ normalises back to where it started), and an undo after a +concurrent remote time change overwrites it, exactly as the forward move would. + ## Reminder delivery Calendula plans and fires its own reminders. It reads the offsets in diff --git a/floret-kit b/floret-kit index ed1d3ca..71a4f37 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit ed1d3ca5e8ff0d63e2ed9d7ece25bcfbcd3be02d +Subproject commit 71a4f371b6dba4f32fc68bac254226c45696c283