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 index c1fda30..24db4bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -52,11 +52,17 @@ 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()) { + val spec: FiniteAnimationSpec = if (rememberReduceMotion() || + LocalTimelineZoom.current.isPinching + ) { snap() } else { MaterialTheme.motionScheme.fastSpatialSpec() 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 index 6bdfc90..2bed934 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -51,6 +51,8 @@ private val FAB_BAND = 88.dp 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 @@ -85,8 +87,12 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) shown.value = ChipContent(message, moved?.undo) } val content = shown.value - LaunchedEffect(outcome) { - if (outcome == null) return@LaunchedEffect + // 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() } @@ -103,13 +109,15 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) .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 { content.undo != null }, - onAction = content.undo?.let { undo -> { viewModel.undo(undo) } }, + actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null }, + onAction = undo?.let { { viewModel.undo(it) } }, ) } } 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 index a1e04fe..6fb0d8a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt @@ -29,7 +29,8 @@ class EventMoveScope( * the repository writes whatever it is handed — so this gate is load-bearing. */ val movableCalendarIds: Set, - val move: (MoveRequest) -> Unit, + /** 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 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 index a474ddc..4d829de 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -40,6 +40,9 @@ 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. */ @@ -182,8 +185,13 @@ class RescheduleViewModel @Inject constructor( val newAnchorDate: LocalDate, ) - fun move(request: MoveRequest) { - if (busy || _scopePrompt.value != null) return + /** + * 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) @@ -200,6 +208,7 @@ class RescheduleViewModel @Inject constructor( _scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign) } } + return true } /** Answer the scope dialog. */ @@ -225,10 +234,11 @@ class RescheduleViewModel @Inject constructor( /** * 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. + * 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) { - if (busy) return + fun undo(undo: MoveUndo): Boolean { + if (busy) return false busy = true _undoStarted.value += 1 viewModelScope.launch { @@ -246,6 +256,7 @@ class RescheduleViewModel @Inject constructor( } busy = false } + return true } /** Clear the outcome once the screen has shown it. */ @@ -309,7 +320,11 @@ class RescheduleViewModel @Inject constructor( // then is the single occurrence, whose exception row carries no rule. updated = shifted.copy(rrule = realigned ?: original.rrule), isRecurring = isRecurring, - canRealign = !isRecurring || !movedDay || realigned != null, + 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 meaningful under wholeDayShift. newAnchorDate = anchorDate(detail, original, zone) @@ -377,6 +392,29 @@ class RescheduleViewModel @Inject constructor( 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 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 index 6cb8a46..9420cdd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -273,7 +273,9 @@ class TimelineDragController { 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) - val span = block.endMin - block.startMin + // 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 @@ -426,8 +428,15 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = ) 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(drag.endMin.coerceAtMost(MINUTES_PER_DAY), use24Hour, locale) + formatMinuteOfDay(endMin, use24Hour, locale) Box( modifier = Modifier // Absolute: these are root coordinates, and the direction-aware 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 5d98f13..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 @@ -346,7 +346,7 @@ private fun DayContent( onEventClick = onEventClick, onCreateAt = onCreateAt, onDrop = { drop -> - move?.move( + val took = move?.move( MoveRequest( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), @@ -354,6 +354,9 @@ private fun DayContent( 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() }, ) } 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 index 750df47..f3dcf3c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt @@ -226,15 +226,16 @@ class MonthDragController { } /** - * End the drag. 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. + * 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 ?: return null + val target = landed?.targetDate?.takeIf { it != landed.grabDate } ?: return null settling = landed.copy( topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y), ) 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 be5ec50..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 @@ -2209,16 +2209,17 @@ private fun monthChipDragModifier( // 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() - if (delta != 0) { - moveScope?.move( - MoveRequest( - eventId = drop.event.eventId, - beginMillis = drop.event.start.toEpochMilliseconds(), - endMillis = drop.event.end.toEpochMilliseconds(), - target = MoveTarget.ByDays(delta), - ), - ) - } + 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() }, 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 e1cac26..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 @@ -382,7 +382,7 @@ private fun WeekContent( onOpenDay = onOpenDay, onCreateAt = onCreateAt, onDrop = { drop -> - move?.move( + val took = move?.move( MoveRequest( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), @@ -390,6 +390,9 @@ private fun WeekContent( 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() }, ) } 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 index 68632e5..c29a521 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -67,11 +67,12 @@ class RescheduleViewModelTest { 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(beginMillis), - end = Instant.fromEpochMilliseconds(endMillis), + 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, @@ -232,6 +233,64 @@ class RescheduleViewModelTest { 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,