From ee23a91acb0f0f4258f44531a3df62b6d93ca4f8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 1 Sep 2026 11:43:54 +0200 Subject: [PATCH] Fix drag-to-reschedule for events spanning midnight (#253) (#257) Dragging an event that crosses midnight now works from either half. - The tail of an overnight event can be picked up and dragged; the drop takes the clip offset back off, so the event lands where its own start belongs, and the second half can be dragged earlier as well as later. - Timed blocks are cut square at midnight instead of being rounded off, and a month row draws an event crossing into the next week as one bar rather than two unrelated pills. - Both halves are previewed while dragging, whichever one is held, and the floating copy carries the time range once. - A moved chip is seated from its own bar's first column, a clipped-tail drop is confirmed against the slice the grid actually draws, and both halves stay ghosted until the write lands. - The drop's arithmetic is wall clock throughout, so a DST transition between the event's start and the midnight its tail is cut at no longer shifts the write by an hour. - A refused drop ticks `abandoned`, so a held copy is let go by one signal instead of by every call site. Closes #253 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/257 --- .../calendula/ui/CalendarHost.kt | 1 + .../calendula/ui/common/EventChipShape.kt | 35 ++ .../calendula/ui/common/EventMoveScope.kt | 19 + .../ui/common/RescheduleViewModel.kt | 26 +- .../calendula/ui/common/TimelineDrag.kt | 408 ++++++++++++++---- .../calendula/ui/day/DayScreen.kt | 31 +- .../calendula/ui/month/MonthDrag.kt | 58 ++- .../calendula/ui/month/MonthScreen.kt | 25 +- .../calendula/ui/month/MonthUiState.kt | 10 + .../calendula/ui/month/MonthViewModel.kt | 6 +- .../calendula/ui/week/WeekScreen.kt | 31 +- .../calendula/ui/week/WeekViewModel.kt | 11 + .../ui/common/RescheduleViewModelTest.kt | 57 +++ .../ui/common/TimedBlockShapeTest.kt | 183 ++++++++ .../calendula/ui/common/TimelineDropTest.kt | 156 +++++++ .../calendula/ui/month/ChipSeatTest.kt | 120 ++++++ .../calendula/ui/month/MonthLayoutTest.kt | 50 +++ 17 files changed, 1083 insertions(+), 144 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventChipShape.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipSeatTest.kt 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 fa32b89..0dd735d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -322,6 +322,7 @@ fun CalendarHost( move = reschedule::move, inFlight = reschedule.inFlight, undoStarted = reschedule.undoStarted, + abandoned = reschedule.abandoned, edit = onEditEvent, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventChipShape.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventChipShape.kt new file mode 100644 index 0000000..6bc0d12 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventChipShape.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp + +/** The corner radius every event chip keeps on each edge the event doesn't cross. */ +val EVENT_CHIP_CORNER = 4.dp + +/** An event chip that is cut on no edge — an all-day bar, a floating copy. */ +val EventChipShape = RoundedCornerShape(EVENT_CHIP_CORNER) + +/** + * A timed block's corners: square on whichever edge the event runs past, so a + * cut edge reads as "this carries on" rather than as the event's own end. The + * vertical counterpart of [monthBarShape]. + */ +fun timedBlockShape(continuesBefore: Boolean, continuesAfter: Boolean): RoundedCornerShape = + RoundedCornerShape( + topStart = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER, + topEnd = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER, + bottomStart = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER, + bottomEnd = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER, + ) + +/** + * A month/all-day bar's corners: square on whichever side the event runs past + * the row it is drawn in. + */ +fun monthBarShape(continuesLeft: Boolean, continuesRight: Boolean): RoundedCornerShape = + RoundedCornerShape( + topStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER, + bottomStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER, + topEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER, + bottomEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER, + ) 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 c338e40..048d663 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 @@ -45,6 +45,12 @@ class EventMoveScope( val inFlight: StateFlow, /** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */ val undoStarted: StateFlow, + /** + * Ticks when a drop ends with nothing landing — see + * `RescheduleViewModel.abandoned`. [move] answers before the write is + * resolved, so this is what tells a held copy to stop waiting. + */ + val abandoned: StateFlow, /** Open an event in the edit form — the pointer-free route to the same change. */ val edit: (EventInstance) -> Unit, ) { @@ -57,6 +63,8 @@ private val NEVER_IN_FLIGHT = MutableStateFlow(false) private val NEVER_UNDONE = MutableStateFlow(0) +private val NEVER_ABANDONED = 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. @@ -67,6 +75,17 @@ fun moveInFlight(): Boolean { return flow.collectAsStateWithLifecycle().value } +/** + * How many drops have ended with nothing landing. Read as a plain count so a + * drag overlay can capture it at the moment of the drop and tell, later, whether + * *its own* drop was one of them. + */ +@Composable +fun abandonedMoves(): Int { + val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED + return flow.collectAsStateWithLifecycle().value +} + /** * Runs [onUndo] when an undo write begins, and never for one that began before * this composable came on screen. 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 21d6257..d243a9d 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 @@ -140,6 +140,17 @@ class RescheduleViewModel @Inject constructor( */ val inFlight: StateFlow = _inFlight.asStateFlow() + private val _abandoned = MutableStateFlow(0) + + /** + * Ticks when a drop ends with nothing on the target day: refused outright, + * refused before the write, written and failed, or its scope dialog + * dismissed. [move] answers before any of that is known, so without this + * signal the view that drew the drop held its copy out for the full settle + * timeout waiting for a grid that was never going to draw it (#253). + */ + val abandoned: StateFlow = _abandoned.asStateFlow() + private val _undoStarted = MutableStateFlow(0) /** @@ -189,15 +200,19 @@ class RescheduleViewModel @Inject constructor( /** * 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. + * be written; [abandoned] ticks for that refusal too, so a held copy is let + * go by the one signal rather than by every caller remembering to. */ fun move(request: MoveRequest): Boolean { - if (busy || _scopePrompt.value != null) return false + if (busy || _scopePrompt.value != null) { + _abandoned.value += 1 + return false + } busy = true viewModelScope.launch { val prepared = prepare(request) if (prepared == null) { + _abandoned.value += 1 busy = false return@launch } @@ -231,6 +246,7 @@ class RescheduleViewModel @Inject constructor( pending = null busy = false _scopePrompt.value = null + _abandoned.value += 1 } /** @@ -340,6 +356,7 @@ class RescheduleViewModel @Inject constructor( val request = prepared.request if (endsBeforeItStarts(prepared, scope)) { _outcome.value = MoveOutcome.BlockedSeriesEnd + _abandoned.value += 1 return } _outcome.value = try { @@ -375,6 +392,9 @@ class RescheduleViewModel @Inject constructor( } catch (e: Exception) { MoveOutcome.Failed } + // Nothing landed, so the copy the view is holding has nothing to fade + // into — let it go now rather than at the end of the settle timeout. + if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1 } /** 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 9420cdd..7760133 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 @@ -25,6 +25,7 @@ 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.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInRoot @@ -44,7 +45,11 @@ 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.coroutines.flow.StateFlow +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone @@ -66,17 +71,109 @@ private val AUTO_SCROLL_STEP = 16.dp data class TimelineDrag( val event: EventInstance, val date: LocalDate, + /** + * Where the dragged *block's* top edge now sits, in minutes from [date]'s + * midnight. The gesture's own quantity — what snaps, what the haptics tick + * on, and what the drop is expressed in. Not the event's start: for the tail + * of an event that began the day before, the two are a clip offset apart. + */ val startMin: Int, - val endMin: Int, - val topLeftInRoot: Offset, - val sizePx: IntSize, + /** The event's new start, from [date]'s midnight — negative if it began earlier. */ + val eventStartMin: Int, + val eventSpanMin: Int, + /** The event as the grid would draw it, one piece per day column it covers. */ + val pieces: List, ) { /** 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) +/** + * One day column's share of a dragged event, ready to draw. Both halves of an + * event crossing midnight are previewed, whichever half the finger picked up: + * showing only the held one made the event read as growing rather than moving, + * since the day beside it still stood at the block's old extent (#253). + */ +data class TimelineDragPiece( + val topLeftInRoot: Offset, + val sizePx: IntSize, + val continuesBefore: Boolean, + val continuesAfter: Boolean, +) + +/** + * How a run of [spanMin] minutes beginning [eventStartMin] minutes into a day — + * negative when it began on an earlier one — falls across day columns, as + * offsets from that day. Every slice but the first and last is a whole day, and + * the flags say which edges are cuts rather than the event's own ends. + * + * [within] bounds the day offsets that have a column to draw in, so a very long + * event doesn't slice days no one can see. + */ +internal fun dragSlices( + eventStartMin: Int, + spanMin: Int, + within: IntRange? = null, +): List { + val span = spanMin.coerceAtLeast(0) + val end = eventStartMin + span + val first = eventStartMin.floorDiv(MINUTES_PER_DAY) + // A zero-length event still has a block, on the one day it sits in. + val last = maxOf((end - 1).floorDiv(MINUTES_PER_DAY), first) + val from = maxOf(first, within?.first ?: first) + val to = minOf(last, within?.last ?: last) + if (from > to) return emptyList() + return (from..to).map { day -> + val dayStart = day * MINUTES_PER_DAY + val sliceStart = maxOf(eventStartMin - dayStart, 0) + val sliceEnd = minOf(end - dayStart, MINUTES_PER_DAY) + DragSlice( + dayOffset = day, + startMin = sliceStart, + spanMin = sliceEnd - sliceStart, + continuesBefore = day > first, + continuesAfter = day < last, + ) + } +} + +/** + * How far above its day's midnight a block's top edge may be dragged. Zero for + * an ordinary block, whose top edge is the event's own start. A tail clipped at + * midnight has to go lower: its top edge *is* midnight and stays there however + * much earlier the event moves, so pinning it at zero made dragging the second + * half earlier a no-op (#253). It may rise until the event's end reaches one + * snap step into this day — or by a single step, for a tail too short to afford + * even that, which then leaves the day for the one before it. + */ +internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int = + if (clipOffsetMin > 0) { + minOf(-DRAG_SNAP_MINUTES, DRAG_SNAP_MINUTES + clipOffsetMin - eventSpanMin) + } else { + 0 + } + +/** One day's share of a dragged event, before it is placed on screen. */ +internal data class DragSlice( + val dayOffset: Int, + val startMin: Int, + val spanMin: Int, + val continuesBefore: Boolean, + val continuesAfter: Boolean, +) + +/** + * Where a finished drag asks its event to go. [date]/[startMin] are where the + * dragged *block's* top edge landed, which is the event's own start only for a + * block that isn't clipped — see [clipOffsetMin] and [startInstant]. + */ +data class TimelineDrop( + val event: EventInstance, + val date: LocalDate, + val startMin: Int, + /** Minutes from the event's start to the dragged block's top edge. */ + val clipOffsetMin: Int = 0, +) /** * The timeline's live geometry, republished on every layout. Plain fields rather @@ -134,12 +231,14 @@ class TimelineDragController { 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. + * The event row and start instant the settled drop came from. The instance id + * alone can't identify the source 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. Matching the start instant rather than a + * single day and slot keeps *both* halves of an event that crosses midnight + * ghosted until the write lands (#253). */ - private var settledOrigin: Triple? by mutableStateOf(null) + private var settledOrigin: Pair? by mutableStateOf(null) /** Whether a finger is on a block right now — [settling] is not dragging. */ var isDragging: Boolean by mutableStateOf(false) @@ -153,18 +252,42 @@ class TimelineDragController { var settledOnGrid: Boolean by mutableStateOf(false) private set + /** + * The abandoned-move counter, read at the drop so the mark can't be a + * composition behind the tick it is compared against. + */ + var abandonedTicks: StateFlow? = null + + /** What [abandonedTicks] stood at when the settling drop was let go. */ + var settledAbandonedMark: Int = 0 + private set + private var source: TimedBlock? = null private var grab = Offset.Zero private var pointer = Offset.Zero + /** + * How far the picked-up block's top edge sits past the event's own start — + * nonzero only for a block clipped at midnight, which is the tail of an + * event that began the day before (#253). The gesture tracks the block the + * finger is on; this is what turns where it lands back into an event start. + */ + private var clipOffsetMin = 0 + /** * 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) { + fun begin( + block: TimedBlock, + clipOffsetMin: Int, + pointerInRoot: Offset, + blockInRoot: Offset, + ) { source = block + this.clipOffsetMin = clipOffsetMin settling = null isDragging = true liftedInstanceId = block.event.instanceId @@ -182,6 +305,7 @@ class TimelineDragController { fun cancel() { source = null + clipOffsetMin = 0 isDragging = false liftedInstanceId = null originSlot = null @@ -192,32 +316,41 @@ class TimelineDragController { } /** - * 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. + * Whether [block] is part of the event whose copy is in flight, and so must + * stay a ghost: the instance the finger picked up, or once dropped whatever + * the re-read now carries at the time it came from — both halves of it, for + * an event that crosses midnight (#253). */ - fun ghosts(block: TimedBlock, date: LocalDate): Boolean { + fun ghosts(block: TimedBlock): 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 + val (eventId, start) = settledOrigin ?: return false + return block.event.eventId == eventId && block.event.start == start } /** * 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`. + * or its title, since a single-occurrence move writes a new `eventId`. A + * blank title matches nothing: every untitled event has one (#253). */ fun noteGrid(date: LocalDate, blocks: List) { val landed = settling ?: return - if (settledOnGrid || landed.date != date) return + if (settledOnGrid) return + // Where this day would draw it, which for a drop clipped at midnight is + // the day's own start rather than the edge the finger held (#253) — and + // which for a tail dragged clear of its day is the day before it. + val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt() + val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset) + .firstOrNull() ?: return settledOnGrid = blocks.any { block -> - block.startMin == landed.startMin && + block.startMin == slice.startMin && ( block.event.eventId == landed.event.eventId || - block.event.title == landed.event.title + ( + landed.event.title.isNotBlank() && + block.event.title == landed.event.title + ) ) } } @@ -230,12 +363,14 @@ class TimelineDragController { fun finish(): TimelineDrop? { val landed = drag val origin = originSlot + val clipOffset = clipOffsetMin 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) + settledOrigin = landed.event.eventId to landed.event.start + settledAbandonedMark = abandonedTicks?.value ?: 0 + return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset) } /** @@ -271,28 +406,44 @@ class TimelineDragController { 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 eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt() + .coerceAtLeast(0) + val floor = dragFloorMin(clipOffsetMin, eventSpan) + val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) + val eventStartMin = startMin - clipOffsetMin 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(), - ), + eventStartMin = eventStartMin, + eventSpanMin = eventSpan, + // Bounded to the columns this timeline actually shows: a day it + // doesn't has no piece to draw. The write is unaffected. + pieces = dragSlices( + eventStartMin, + eventSpan, + within = -dayIndex..days.lastIndex - dayIndex, + ).map { slice -> + val day = dayIndex + slice.dayOffset + val col = if (geometry.isRtl) days.lastIndex - day else day + TimelineDragPiece( + topLeftInRoot = Offset( + x = origin.x + col * columnPx, + y = origin.y + slice.startMin / 60f * hourPx, + ), + sizePx = IntSize( + (columnPx - geometry.columnGapPx).roundToInt(), + maxOf( + slice.spanMin / 60f * hourPx, + MIN_EVENT_FRACTION * hourPx, + ).roundToInt(), + ), + continuesBefore = slice.continuesBefore, + continuesAfter = slice.continuesAfter, + ) + }, ) } @@ -334,24 +485,52 @@ fun rememberTimelineDragController(): TimelineDragController { val density = LocalDensity.current controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() } controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() } + controller.abandonedTicks = LocalEventMove.current?.abandoned 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. + * How far this block's top edge sits past its event's start, in minutes — zero + * for a block that begins on [day], and the part already run for the tail of an + * event that started earlier, whose top edge is midnight rather than the event's + * start. Dragging such a tail moves the event by where *its own* top edge lands, + * so the offset has to come back off at the drop (#253). */ -fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean = - event.start.toLocalDateTime(zone).date == day +fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int { + val start = event.start.toLocalDateTime(zone) + val days = (day.toEpochDays() - start.date.toEpochDays()).toInt() + val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60 + return minutes.coerceAtLeast(0) +} /** - * 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. + * True when the event began before [day] — this block is its tail, cut at + * midnight rather than starting where the event does. */ -fun TimelineDrop.startInstant(zone: TimeZone): Instant = - LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone) +fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean = + event.start < day.atStartOfDayIn(zone) + +/** True when the event runs past [day]'s midnight, so this block is cut there. */ +fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean = + event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) + +/** + * The instant a drop asks for — the *event's* new start, so the tail of an + * overnight event drops back onto the day its own start belongs to. 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 { + // Wall clock throughout, and past this day at either end: a block's top edge + // can be dragged above its midnight and a tail's below the next one, so the + // start is resolved as a day plus a minute of it rather than as an offset + // from an instant — which would shift by an hour across a DST boundary. + val fromMidnight = startMin - clipOffsetMin + val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY) + val minute = fromMidnight.mod(MINUTES_PER_DAY) + return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone) +} /** * A beat after the grid draws the drop, so its own block is under way before the @@ -384,7 +563,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = 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 @@ -397,9 +575,14 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = // 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) { + val abandoned = abandonedMoves() + LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) { if (controller.settling == null || moveInFlight) return@LaunchedEffect - delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS) + // Past the mark this drop was let go on, it was abandoned: nothing will + // land and there is nothing to wait for. + if (abandoned == controller.settledAbandonedMark) { + delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS) + } handingOver = true controller.handOver() delay(SETTLE_FADE_MILLIS.toLong()) @@ -428,49 +611,84 @@ 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)}–" + + // Read off the event rather than the held block, so grabbing either half + // of an event that crosses midnight names the same hours. 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 startMin = drag.eventStartMin.mod(MINUTES_PER_DAY) + val rawEnd = startMin + drag.eventSpanMin + val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd + val label = "${formatMinuteOfDay(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), + // The tallest piece carries the range: on the smallest it would be + // clipped away, which is exactly the case when a short tail is held. + val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height } + drag.pieces.forEachIndexed { index, piece -> + DragCopy( + topLeftInRoot = piece.topLeftInRoot, + overlayOrigin = origin, + sizePx = piece.sizePx, + fill = fill, + shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter), + lift = lift, + alpha = copyAlpha, + title = title, + // Once for the whole event: repeated, it would name it per day. + label = label.takeIf { index == labelled }, + ) + } + } +} + +/** One piece of a dragged block, floating over the calendar. */ +@Composable +private fun DragCopy( + topLeftInRoot: Offset, + overlayOrigin: Offset, + sizePx: IntSize, + fill: Color, + shape: RoundedCornerShape, + lift: Float, + alpha: Float, + title: String, + label: String?, +) { + val density = LocalDensity.current + 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( + (topLeftInRoot.x - overlayOrigin.x).roundToInt(), + (topLeftInRoot.y - overlayOrigin.y).roundToInt(), ) + } + .size( + width = with(density) { sizePx.width.toDp() }, + height = with(density) { sizePx.height.toDp() }, + ) + .padding(horizontal = 1.dp) + .graphicsLayer { + scaleX = 1f + 0.02f * lift + scaleY = 1f + 0.02f * lift + shadowElevation = 8.dp.toPx() * lift + this.alpha = alpha + this.shape = shape + clip = false + } + .background(fill, shape) + .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), + ) + if (label != null) { Text( text = label, style = MaterialTheme.typography.labelSmall, 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 712e7a8..42b1e74 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 @@ -91,7 +91,10 @@ 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.clipOffsetMinutes +import de.jeanlucmakiola.calendula.ui.common.continuesAfter +import de.jeanlucmakiola.calendula.ui.common.continuesBefore +import de.jeanlucmakiola.calendula.ui.common.timedBlockShape import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource @@ -104,6 +107,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.declinedDecoration @@ -351,7 +355,7 @@ private fun DayContent( onEventClick = onEventClick, onCreateAt = onCreateAt, onDrop = { drop -> - val took = move?.move( + move?.move( MoveRequest( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), @@ -359,9 +363,6 @@ 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() }, ) } @@ -519,7 +520,7 @@ private fun AllDayBar( val fill = eventFill(event.color, dark, soften) Box( modifier = modifier - .background(fill, RoundedCornerShape(4.dp)) + .background(fill, EventChipShape) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) .semantics { contentDescription = title }, @@ -744,24 +745,30 @@ private fun EventBlock( 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 = eventDragAllowed(block.event) && block.beginsOn(date, zone) + val draggable = eventDragAllowed(block.event) + // The drop takes this offset back off, so a tail clipped at midnight lands + // where the event's own start belongs (#253). + val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } + val shape = remember(block, date, zone) { + timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) + } val dragModifier = rememberEventDragSource( enabled = draggable, key = block.event.instanceId, - onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, + onPickUp = { pointer, blockRoot, _ -> + dragController.begin(block, clipOffset, pointer, blockRoot) + }, onMove = dragController::move, onDrop = { dragController.finish()?.let(onDrop) }, onCancel = dragController::cancel, ) - val lifted = draggable && dragController.ghosts(block, date) + val lifted = draggable && dragController.ghosts(block) 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)) + .background(fill, shape) .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. 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 f3dcf3c..809a618 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 @@ -13,6 +13,8 @@ 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 de.jeanlucmakiola.calendula.ui.common.LocalEventMove +import kotlinx.coroutines.flow.StateFlow import kotlinx.datetime.LocalDate import kotlin.math.abs @@ -33,6 +35,8 @@ class MonthRowGeometry( val isRtl: Boolean, /** What this row currently draws in [lane] of column `col`, or null. */ val chipAt: (col: Int, lane: Int) -> EventInstance?, + /** The column the chip in [lane] of column `col` is drawn from. */ + val chipStart: (col: Int, lane: Int) -> Int, ) { /** Root top-left of the chip seated in [lane] of column [col]. */ fun seat(col: Int, lane: Int): Offset? { @@ -47,21 +51,43 @@ class MonthRowGeometry( * 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 + val (col, lane) = chipSeat(days, laneCount, chipAt, chipStart, event, date) ?: return null return seat(col, lane) } } +/** + * The column and lane a row draws [event]'s chip in, given it covers [date] — + * null when the row seats no such chip. The column is the *left end* of the bar + * within this row, not [date]'s own: a bar spanning several days is drawn once, + * from its first column, so an event dropped onto the second day it covers is + * seated a column (or more) to the left of where it landed (#253). + */ +internal fun chipSeat( + days: List, + laneCount: Int, + chipAt: (col: Int, lane: Int) -> EventInstance?, + chipStart: (col: Int, lane: Int) -> Int, + event: EventInstance, + date: LocalDate, +): Pair? { + 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 chipStart(col, lane) to 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. + * move writes a new event id — the title survives both. A *blank* title is no + * evidence at all, though: every untitled event carries one, so matching on it + * made any two of them the same event (#253). */ -private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean = - chip.eventId == moved.eventId || chip.title == moved.title +internal fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean = + chip.eventId == moved.eventId || + (moved.title.isNotBlank() && chip.title == moved.title) /** A chip in flight, in root coordinates so it can be drawn in an overlay. */ data class MonthChipDrag( @@ -134,6 +160,16 @@ class MonthDragController { */ private var undoable: MonthChipDrag? = null + /** + * The abandoned-move counter, read at the drop so the mark can't be a + * composition behind the tick it is compared against. + */ + var abandonedTicks: StateFlow? = null + + /** What [abandonedTicks] stood at when the settling chip was let go. */ + var settledAbandonedMark: Int = 0 + private set + private var event: EventInstance? = null private var grabDate: LocalDate? = null private var grab = Offset.Zero @@ -222,6 +258,7 @@ class MonthDragController { liftedInstanceId = last.event.instanceId settledGhost = true settledInRoot = null + settledAbandonedMark = abandonedTicks?.value ?: 0 return true } @@ -242,6 +279,7 @@ class MonthDragController { liftedInstanceId = landed.event.instanceId settledGhost = true undoable = settling + settledAbandonedMark = abandonedTicks?.value ?: 0 return MonthChipDrop(landed.event, landed.grabDate, target) } @@ -298,6 +336,10 @@ class MonthDragController { } @Composable -fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() } +fun rememberMonthDragController(): MonthDragController { + val controller = remember { MonthDragController() } + controller.abandonedTicks = LocalEventMove.current?.abandoned + return controller +} 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 a929d87..2a510f2 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 @@ -95,6 +95,7 @@ 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.abandonedMoves import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics @@ -145,6 +146,8 @@ import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventAccent +import de.jeanlucmakiola.calendula.ui.common.EventChipShape +import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute @@ -505,11 +508,14 @@ private fun MonthDragOverlay(controller: MonthDragController) { val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) } val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec() val seat = controller.settledInRoot - LaunchedEffect(controller.settling, moveInFlight, seat) { + val abandoned = abandonedMoves() + LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) { val landed = controller.settling if (landed == null || moveInFlight) return@LaunchedEffect if (seat == null) { - delay(MONTH_SETTLE_TIMEOUT_MILLIS) + // Past the mark this drop was let go on, it was abandoned and no + // seat is ever coming. + if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS) } else { if (!gliding) glide.snapTo(landed.topLeftInRoot) gliding = true @@ -563,7 +569,7 @@ private fun MonthDragOverlay(controller: MonthDragController) { scaleY = 1f + 0.04f * lift shadowElevation = 8.dp.toPx() * lift alpha = copyAlpha - shape = RoundedCornerShape(4.dp) + shape = EventChipShape clip = false }, ) @@ -1919,6 +1925,7 @@ private fun MonthWeekRow( laneCount = MAX_EVENT_ROWS, isRtl = isRtl, chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) }, + chipStart = { col, lane -> week.chipStartCol(col, lane) }, ), ) } @@ -2310,7 +2317,7 @@ 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() - val took = moveScope?.move( + moveScope?.move( MoveRequest( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), @@ -2318,9 +2325,6 @@ private fun monthChipDragModifier( 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() }, @@ -2436,12 +2440,7 @@ private fun MonthBar( 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, - topEnd = if (continuesRight) 0.dp else 4.dp, - bottomEnd = if (continuesRight) 0.dp else 4.dp, - ) + val shape = monthBarShape(continuesLeft, continuesRight) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) 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 56624d4..434760e 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 @@ -84,6 +84,16 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? { return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index) } +/** + * The column the chip drawn in lane [lane] of column [col] begins in. A bar is + * drawn once, from its own first column, so a chip seated by [chipAt] on the + * second day it covers belongs a column (or more) to the left (#253). Answers + * [col] for a single-day chip, and for an empty slot no one should be asking + * about. + */ +fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = + spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col + /** * 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/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index c4cab41..f15345a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay +import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -417,8 +418,11 @@ internal fun layoutCalendarWeek( zone: TimeZone, ): MonthWeek { val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } } + // A bar is decided by the *event*, not by how much of it this row holds: one + // crossing midnight into the next week row covers a single day in each, and + // counting per row drew it as two unrelated pills (#253). val (bars, singles) = weekEvents.partition { ev -> - ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1 + ev.isAllDay || ev.spansMultipleDays(zone) } val spans = layoutAllDay(bars, days, zone).map { s -> MonthSpan( 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 10b3fd1..6128fb8 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 @@ -100,7 +100,10 @@ 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.clipOffsetMinutes +import de.jeanlucmakiola.calendula.ui.common.continuesAfter +import de.jeanlucmakiola.calendula.ui.common.continuesBefore +import de.jeanlucmakiola.calendula.ui.common.timedBlockShape import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource @@ -108,6 +111,7 @@ 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.EventChipShape import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.NowLine @@ -387,7 +391,7 @@ private fun WeekContent( onOpenDay = onOpenDay, onCreateAt = onCreateAt, onDrop = { drop -> - val took = move?.move( + move?.move( MoveRequest( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), @@ -395,9 +399,6 @@ 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() }, ) } @@ -656,7 +657,7 @@ private fun AllDayBar( val fill = eventFill(event.color, dark, soften) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) - .background(fill, RoundedCornerShape(4.dp)) + .background(fill, EventChipShape) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) .semantics { contentDescription = title }, @@ -912,24 +913,30 @@ private fun EventBlock( 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 = eventDragAllowed(block.event) && block.beginsOn(date, zone) + val draggable = eventDragAllowed(block.event) + // The drop takes this offset back off, so a tail clipped at midnight lands + // where the event's own start belongs (#253). + val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } + val shape = remember(block, date, zone) { + timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) + } val dragModifier = rememberEventDragSource( enabled = draggable, key = block.event.instanceId, - onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, + onPickUp = { pointer, blockRoot, _ -> + dragController.begin(block, clipOffset, pointer, blockRoot) + }, onMove = dragController::move, onDrop = { dragController.finish()?.let(onDrop) }, onCancel = dragController::cancel, ) - val lifted = draggable && dragController.ghosts(block, date) + val lifted = draggable && dragController.ghosts(block) 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)) + .background(fill, shape) .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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 71f603c..81a3b49 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -206,6 +206,17 @@ internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean { return start < dayEnd && end > dayStart } +/** + * True if this event touches more than one calendar day in [zone] — an all-day + * event covering a range, or a timed one crossing midnight. Occurrences are + * contiguous, so covering the day after the first is the whole question. + */ +internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean { + val anchor = if (isAllDay) TimeZone.UTC else zone + val secondDay = start.toLocalDateTime(anchor).date.plus(1, DateTimeUnit.DAY) + return coversDay(secondDay, zone) +} + /** * Clip [events] to a single [day] and assign lanes so overlapping events render * side-by-side. Lane count is computed per overlap-cluster (a maximal run of 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 c29a521..22a6b3c 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 @@ -494,4 +494,61 @@ class RescheduleViewModelTest { assertThat(fake.updatedEvents).isEmpty() assertThat(fake.updatedOccurrences).isEmpty() } + + @Test + fun `a drop that lands nothing says so, since move() already answered yes`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } } + val vm = viewModel(tempDir, fake) + + // Refused in prepare: nowhere to move to. + assertThat(vm.move( + MoveRequest( + eventId = 42L, + beginMillis = beginMillis, + endMillis = endMillis, + target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)), + ), + )).isTrue() + advanceUntilIdle() + assertThat(vm.abandoned.value).isEqualTo(1) + + // Refused by the provider, after the write was attempted. + fake.writeError = SecurityException("revoked") + vm.move(oneHourLater()) + advanceUntilIdle() + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied) + assertThat(vm.abandoned.value).isEqualTo(2) + } + + @Test + fun `a scope dialog dismissed leaves nothing waiting on the write`( + @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.abandoned.value).isEqualTo(0) + + vm.cancelScope() + advanceUntilIdle() + assertThat(vm.abandoned.value).isEqualTo(1) + } + + @Test + fun `a written move never counts as abandoned`(@TempDir tempDir: Path) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + advanceUntilIdle() + + assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java) + assertThat(vm.abandoned.value).isEqualTo(0) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt new file mode 100644 index 0000000..5a2a7eb --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt @@ -0,0 +1,183 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY +import de.jeanlucmakiola.calendula.ui.week.layoutDay +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.ui.unit.dp +import org.junit.jupiter.api.Test + +/** + * Which edges a timed block is cut on, and how much of a dragged piece its day + * can show (#253). + */ +class TimedBlockShapeTest { + + private val zone = TimeZone.UTC + private val day1 = LocalDate(2026, 9, 5) + private val day2 = day1.plus(1, DateTimeUnit.DAY) + private val day3 = day2.plus(1, DateTimeUnit.DAY) + + private fun event(from: LocalDateTimeIsh, to: LocalDateTimeIsh) = EventInstance( + instanceId = 1L, eventId = 1L, calendarId = 1L, title = "E", + start = from.date.atTime(from.hour, 0).toInstant(zone), + end = to.date.atTime(to.hour, 0).toInstant(zone), + isAllDay = false, color = 0xFF112233.toInt(), location = null, + ) + + data class LocalDateTimeIsh(val date: LocalDate, val hour: Int) + + private fun at(date: LocalDate, hour: Int) = LocalDateTimeIsh(date, hour) + + @Test + fun `a same-day block is cut on neither edge`() { + val ev = event(at(day1, 9), at(day1, 10)) + val block = layoutDay(listOf(ev), day1, zone).single() + + assertThat(block.continuesBefore(day1, zone)).isFalse() + assertThat(block.continuesAfter(day1, zone)).isFalse() + } + + @Test + fun `an overnight event is cut at the foot of its first day and the head of its second`() { + val ev = event(at(day1, 20), at(day2, 8)) + + val head = layoutDay(listOf(ev), day1, zone).single() + assertThat(head.continuesBefore(day1, zone)).isFalse() + assertThat(head.continuesAfter(day1, zone)).isTrue() + + val tail = layoutDay(listOf(ev), day2, zone).single() + assertThat(tail.continuesBefore(day2, zone)).isTrue() + assertThat(tail.continuesAfter(day2, zone)).isFalse() + } + + @Test + fun `a day wholly inside a long event is cut on both edges`() { + val ev = event(at(day1, 20), at(day3, 8)) + val middle = layoutDay(listOf(ev), day2, zone).single() + + assertThat(middle.continuesBefore(day2, zone)).isTrue() + assertThat(middle.continuesAfter(day2, zone)).isTrue() + } + + @Test + fun `an event ending exactly at midnight is not cut`() { + val ev = event(at(day1, 20), at(day2, 0)) + val block = layoutDay(listOf(ev), day1, zone).single() + + assertThat(block.continuesAfter(day1, zone)).isFalse() + } + + @Test + fun `only the cut edges lose their corners`() { + val square = 0.dp + assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).topStart) + .isEqualTo(CornerSize(square)) + assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).bottomStart) + .isEqualTo(CornerSize(EVENT_CHIP_CORNER)) + assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).topStart) + .isEqualTo(CornerSize(EVENT_CHIP_CORNER)) + assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).bottomEnd) + .isEqualTo(CornerSize(square)) + } + + private val fourHours = 4 * 60 + + @Test + fun `an event that fits its day is drawn as one uncut piece`() { + val pieces = dragSlices(eventStartMin = 10 * 60, spanMin = fourHours) + + assertThat(pieces).hasSize(1) + assertThat(pieces.single().dayOffset).isEqualTo(0) + assertThat(pieces.single().spanMin).isEqualTo(fourHours) + assertThat(pieces.single().continuesBefore).isFalse() + assertThat(pieces.single().continuesAfter).isFalse() + } + + @Test + fun `dragging the start half re-splits both days as it moves`() { + // Held at 22:00: two hours on this day, two on the next. + assertThat(dragSlices(22 * 60, fourHours).map { it.spanMin }) + .containsExactly(2 * 60, 2 * 60).inOrder() + // Carried up to 21:00 the first day takes three, so the second day's end + // comes up by exactly the hour the first day gained. + assertThat(dragSlices(21 * 60, fourHours).map { it.spanMin }) + .containsExactly(3 * 60, 60).inOrder() + } + + @Test + fun `dragging the end half splits the same way, one day back`() { + // The tail's top edge is at midnight and the event began two hours + // earlier, so it starts at -120 relative to the day the finger is on. + val pieces = dragSlices(eventStartMin = -2 * 60, spanMin = fourHours) + + assertThat(pieces.map { it.dayOffset }).containsExactly(-1, 0).inOrder() + assertThat(pieces.map { it.spanMin }).containsExactly(2 * 60, 2 * 60).inOrder() + // The piece on the earlier day ends at midnight; the held one starts there. + assertThat(pieces.first().startMin).isEqualTo(22 * 60) + assertThat(pieces.first().continuesAfter).isTrue() + assertThat(pieces.last().startMin).isEqualTo(0) + assertThat(pieces.last().continuesBefore).isTrue() + } + + @Test + fun `the earlier half shrinks away as the end half is pulled down its own day`() { + // Tail top dragged from midnight down to 01:00: one hour left behind. + assertThat(dragSlices(-2 * 60 + 60, fourHours).map { it.spanMin }) + .containsExactly(60, 3 * 60).inOrder() + // At 02:00 the event no longer crosses midnight at all. + val whole = dragSlices(0, fourHours) + assertThat(whole).hasSize(1) + assertThat(whole.single().continuesBefore).isFalse() + } + + @Test + fun `a day wholly inside a long event is cut on both sides`() { + val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 30 * 60) + + assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder() + assertThat(pieces[1].spanMin).isEqualTo(MINUTES_PER_DAY) + assertThat(pieces[1].continuesBefore).isTrue() + assertThat(pieces[1].continuesAfter).isTrue() + } + + @Test + fun `an event ending exactly at midnight takes no piece of the next day`() { + val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 2 * 60) + + assertThat(pieces).hasSize(1) + assertThat(pieces.single().continuesAfter).isFalse() + } + + @Test + fun `a zero-length event still has a piece to draw`() { + val pieces = dragSlices(eventStartMin = 9 * 60, spanMin = 0) + + assertThat(pieces).hasSize(1) + assertThat(pieces.single().dayOffset).isEqualTo(0) + assertThat(pieces.single().startMin).isEqualTo(9 * 60) + assertThat(pieces.single().spanMin).isEqualTo(0) + } + + @Test + fun `only the days there are columns for are sliced`() { + // A week's worth of event, previewed on a timeline showing three days + // from the one the finger is on. + val pieces = dragSlices(0, 7 * MINUTES_PER_DAY, within = 0..2) + + assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder() + assertThat(pieces.last().continuesAfter).isTrue() + } + + @Test + fun `an event outside the columns shown is sliced into nothing`() { + assertThat(dragSlices(-3 * MINUTES_PER_DAY, 60, within = 0..2)).isEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt new file mode 100644 index 0000000..9ee1646 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt @@ -0,0 +1,156 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.week.TimedBlock +import de.jeanlucmakiola.calendula.ui.week.layoutDay +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * What a timeline drop asks for, for the head and the tail of an event that + * crosses midnight (#253). The tail's top edge is midnight rather than the + * event's start, so the drop has to take that offset back off. + */ +class TimelineDropTest { + + private val zone = TimeZone.UTC + private val sat = LocalDate(2026, 9, 5) + private val sun = sat.plus(1, DateTimeUnit.DAY) + + // Sat 5 Sep 20:00 to Sun 6 Sep 08:00. + private val overnight = EventInstance( + instanceId = 1L, + eventId = 1L, + calendarId = 1L, + title = "Night shift", + start = sat.atTime(20, 0).toInstant(zone), + end = sun.atTime(8, 0).toInstant(zone), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + + private fun blockOn(day: LocalDate): TimedBlock = + layoutDay(listOf(overnight), day, zone).single() + + @Test + fun `the head block carries no clip offset`() { + val head = blockOn(sat) + assertThat(head.startMin).isEqualTo(20 * 60) + assertThat(head.clipOffsetMinutes(sat, zone)).isEqualTo(0) + } + + @Test + fun `the tail block is offset by the part that already ran`() { + val tail = blockOn(sun) + assertThat(tail.startMin).isEqualTo(0) + // 20:00 to midnight is four hours of the event already gone. + assertThat(tail.clipOffsetMinutes(sun, zone)).isEqualTo(4 * 60) + } + + @Test + fun `dropping the head asks for exactly where it landed`() { + val target = LocalDate(2026, 9, 2) + val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0) + + assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone)) + } + + @Test + fun `dropping the tail moves the event by the days the tail travelled`() { + val tail = blockOn(sun) + val offset = tail.clipOffsetMinutes(sun, zone) + // Dragged three days back, same height in the column: Sun 6 -> Thu 3. + val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset) + + // The event began Sat 5 Sep 20:00, so it now begins Wed 2 Sep 20:00 — + // the whole event moved three days, and no time of day was invented. + assertThat(drop.startInstant(zone)) + .isEqualTo(LocalDate(2026, 9, 2).atTime(20, 0).toInstant(zone)) + } + + @Test + fun `dragging the tail down the column re-times the event across midnight`() { + val offset = blockOn(sun).clipOffsetMinutes(sun, zone) + // Tail top pulled from 00:00 to 03:00 on its own day. + val drop = TimelineDrop(overnight, sun, 3 * 60, offset) + + assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone)) + } + + @Test + fun `pulling the tail above its own midnight moves the event earlier`() { + val offset = blockOn(sun).clipOffsetMinutes(sun, zone) + // The tail's top edge dragged an hour above the midnight it rests on. + val drop = TimelineDrop(overnight, sun, -60, offset) + + // Event was Sat 20:00 to Sun 08:00; it now begins an hour earlier. + assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(19, 0).toInstant(zone)) + } + + @Test + fun `a top edge dragged past the next midnight resolves onto the day after`() { + val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0) + + assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone)) + } + + @Test + fun `a short tail can still be dragged a step earlier`() { + // 23:50 to 00:05: five minutes of tail, less than one snap step, which + // a floor pinned at midnight left with nowhere to go (#253). + assertThat(dragFloorMin(clipOffsetMin = 10, eventSpanMin = 15)) + .isEqualTo(-DRAG_SNAP_MINUTES) + } + + @Test + fun `a long tail may rise until its event ends just inside the day`() { + // Sat 20:00 to Sun 08:00, held by the Sunday half. + assertThat(dragFloorMin(clipOffsetMin = 4 * 60, eventSpanMin = 12 * 60)) + .isEqualTo(DRAG_SNAP_MINUTES - 8 * 60) + } + + @Test + fun `an unclipped block cannot be dragged above its own midnight`() { + assertThat(dragFloorMin(clipOffsetMin = 0, eventSpanMin = 60)).isEqualTo(0) + } + + @Test + fun `a drop across a DST boundary keeps the time of day it was let go at`() { + // Europe/Berlin springs forward at 02:00 on 29 March 2026, between the + // event's start and the midnight its tail is cut at. + val berlin = TimeZone.of("Europe/Berlin") + val sun = LocalDate(2026, 3, 29) + val overnightIntoDst = EventInstance( + instanceId = 3L, eventId = 3L, calendarId = 1L, title = "Night shift", + start = LocalDate(2026, 3, 28).atTime(22, 0).toInstant(berlin), + end = sun.atTime(6, 0).toInstant(berlin), + isAllDay = false, color = 0xFF112233.toInt(), location = null, + ) + val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single() + val offset = tail.clipOffsetMinutes(sun, berlin) + // Dropped back onto its own slot a week earlier, clear of the boundary. + val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset) + + assertThat(drop.startInstant(berlin)) + .isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin)) + } + + @Test + fun `a block that is not clipped is unaffected wherever it sits`() { + val plain = EventInstance( + instanceId = 2L, eventId = 2L, calendarId = 1L, title = "Standup", + start = sat.atTime(9, 0).toInstant(zone), + end = sat.atTime(10, 0).toInstant(zone), + isAllDay = false, color = 0xFF112233.toInt(), location = null, + ) + val block = layoutDay(listOf(plain), sat, zone).single() + assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipSeatTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipSeatTest.kt new file mode 100644 index 0000000..710b2ad --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipSeatTest.kt @@ -0,0 +1,120 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * Where a week row draws a moved chip — what the settling copy glides onto + * (#68, #253). + */ +class ChipSeatTest { + + private val monday = LocalDate(2026, 6, 8) + private val days = (0..6).map { monday.plus(it, DateTimeUnit.DAY) } + + private fun event(id: Long, title: String = "E") = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = monday.atTime(9, 0).toInstant(TimeZone.UTC), + end = monday.atTime(10, 0).toInstant(TimeZone.UTC), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + + /** + * A grid holding [seated] in lane 0 as one bar across [cols], and nothing + * else — the seating a [MonthWeek] gives a span. + */ + private fun row(seated: EventInstance, cols: IntRange) = MonthWeek( + days = days, + spans = listOf( + MonthSpan( + event = seated, + startCol = cols.first, + endCol = cols.last, + lane = 0, + continuesLeft = false, + continuesRight = false, + ), + ), + timedByDay = emptyMap(), + countByDay = emptyMap(), + ) + + /** A grid holding one single-day pill per day of [cols], all of [seated]. */ + private fun pills(seated: EventInstance, cols: IntRange) = MonthWeek( + days = days, + spans = emptyList(), + timedByDay = cols.associate { days[it] to listOf(seated) }, + countByDay = emptyMap(), + ) + + private fun seat(week: MonthWeek, event: EventInstance, date: LocalDate) = chipSeat( + days = days, + laneCount = 3, + chipAt = { col, lane -> week.chipAt(col, lane, 3) }, + chipStart = { col, lane -> week.chipStartCol(col, lane) }, + event = event, + date = date, + ) + + @Test + fun `a single-day chip is seated in its own column`() { + val ev = event(1L) + assertThat(seat(row(ev, 3..3), ev, days[3])).isEqualTo(3 to 0) + } + + @Test + fun `a bar is seated from its first column, not the day it was dropped on`() { + val ev = event(1L) + // Covers Wed–Thu; dropped on the Thursday it now runs into. + assertThat(seat(row(ev, 2..3), ev, days[3])).isEqualTo(2 to 0) + } + + @Test + fun `a bar carried in from the previous week is seated at column zero`() { + val ev = event(1L) + assertThat(seat(row(ev, 0..4), ev, days[4])).isEqualTo(0 to 0) + } + + @Test + fun `a row that does not hold the event seats nothing`() { + val ev = event(1L) + assertThat(seat(row(ev, 2..3), ev, days[5])).isNull() + assertThat(seat(row(ev, 2..3), ev, LocalDate(2026, 7, 1))).isNull() + } + + @Test + fun `a re-read chip is matched by title once its event id has changed`() { + // A single-occurrence move writes a new event id; the title survives. + val moved = event(1L, title = "Standup") + val reRead = event(9L, title = "Standup") + assertThat(seat(row(reRead, 3..3), moved, days[3])).isEqualTo(3 to 0) + } + + @Test + fun `two untitled events are not the same event`() { + val moved = event(1L, title = "") + val other = event(2L, title = "") + assertThat(isSameEvent(other, moved)).isFalse() + assertThat(seat(row(other, 3..3), moved, days[3])).isNull() + } + + @Test + fun `a daily series seats each occurrence in its own column`() { + // Every column holds an occurrence of the same event id, so a walk left + // by identity would run to the start of the row (#253). + val ev = event(1L, title = "Standup") + assertThat(seat(pills(ev, 0..6), ev, days[4])).isEqualTo(4 to 0) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt index 52e43f8..e4fc21d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -44,6 +44,19 @@ class MonthLayoutTest { location = null, ) + /** A timed event running from [date] 20:00 into the next day at [endHour]. */ + private fun overnight(date: LocalDate, endHour: Int = 8) = EventInstance( + instanceId = 7L, + eventId = 7L, + calendarId = 1L, + title = "Night shift", + start = at(date, 20), + end = at(date.plus(1, DateTimeUnit.DAY), endHour), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + /** All-day events live at UTC midnights with an *exclusive* end. */ private fun allDay( from: LocalDate, @@ -162,6 +175,43 @@ class MonthLayoutTest { assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty() } + @Test + fun `an event crossing midnight is one bar, not two pills, on both sides of a week seam`() { + // Sun 14 Jun 20:00 to Mon 15 Jun 08:00 — the seam of a Monday-anchored + // grid: one day in this row, one in the next. Counting per row made each + // a standalone pill with nothing saying they were one event (#253). + val overnight = overnight(LocalDate(2026, 6, 14)) + val head = layoutCalendarWeek(weekOf8th, listOf(overnight), zone) + assertThat(head.timedByDay.values.flatten()).isEmpty() + val headSpan = head.spans.single() + assertThat(headSpan.startCol).isEqualTo(6) + assertThat(headSpan.endCol).isEqualTo(6) + assertThat(headSpan.continuesLeft).isFalse() + assertThat(headSpan.continuesRight).isTrue() + + val tailRow = layoutCalendarWeek( + weekOf8th.map { it.plus(7, DateTimeUnit.DAY) }, + listOf(overnight), + zone, + ) + assertThat(tailRow.timedByDay.values.flatten()).isEmpty() + val tailSpan = tailRow.spans.single() + assertThat(tailSpan.startCol).isEqualTo(0) + assertThat(tailSpan.endCol).isEqualTo(0) + assertThat(tailSpan.continuesLeft).isTrue() + assertThat(tailSpan.continuesRight).isFalse() + } + + @Test + fun `an event ending exactly at midnight stays a single-day pill`() { + val untilMidnight = overnight(LocalDate(2026, 6, 10), endHour = 0) + val week = layoutCalendarWeek(weekOf8th, listOf(untilMidnight), zone) + + assertThat(week.spans).isEmpty() + assertThat(week.timedByDay[LocalDate(2026, 6, 10)]).hasSize(1) + assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty() + } + @Test fun `countByDay totals bars and pills on each date`() { val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)