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 <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/257
This commit is contained in:
Jean-Luc Makiola
2026-09-01 11:43:54 +02:00
parent 9bf0788558
commit ee23a91acb
17 changed files with 1083 additions and 144 deletions

View File

@@ -322,6 +322,7 @@ fun CalendarHost(
move = reschedule::move,
inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted,
abandoned = reschedule.abandoned,
edit = onEditEvent,
)
}

View File

@@ -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,
)

View File

@@ -45,6 +45,12 @@ class EventMoveScope(
val inFlight: StateFlow<Boolean>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Int>,
/**
* 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<Int>,
/** 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.

View File

@@ -140,6 +140,17 @@ class RescheduleViewModel @Inject constructor(
*/
val inFlight: StateFlow<Boolean> = _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<Int> = _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
}
/**

View File

@@ -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<TimelineDragPiece>,
) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */
val slot: Pair<LocalDate, Int> 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<DragSlice> {
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<Long, LocalDate, Int>? by mutableStateOf(null)
private var settledOrigin: Pair<Long, Instant>? 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<Int>? = 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<LocalDate, Int>? = 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<TimedBlock>) {
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:0002: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:0002:00" and not "22:0024: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:0002:00" and not "22:0024: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,

View File

@@ -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.

View File

@@ -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<LocalDate>,
laneCount: Int,
chipAt: (col: Int, lane: Int) -> EventInstance?,
chipStart: (col: Int, lane: Int) -> Int,
event: EventInstance,
date: LocalDate,
): Pair<Int, Int>? {
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<Int>? = 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<MonthDragController?> { null }

View File

@@ -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<Offset>()
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)

View File

@@ -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

View File

@@ -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(

View File

@@ -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.

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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 WedThu; 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)
}
}

View File

@@ -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)