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, move = reschedule::move,
inFlight = reschedule.inFlight, inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted, undoStarted = reschedule.undoStarted,
abandoned = reschedule.abandoned,
edit = onEditEvent, 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>, val inFlight: StateFlow<Boolean>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */ /** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Int>, 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. */ /** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit, val edit: (EventInstance) -> Unit,
) { ) {
@@ -57,6 +63,8 @@ private val NEVER_IN_FLIGHT = MutableStateFlow(false)
private val NEVER_UNDONE = MutableStateFlow(0) 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. * 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. * The drag overlays hold a landed block on its target for this window.
@@ -67,6 +75,17 @@ fun moveInFlight(): Boolean {
return flow.collectAsStateWithLifecycle().value 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 * Runs [onUndo] when an undo write begins, and never for one that began before
* this composable came on screen. * this composable came on screen.

View File

@@ -140,6 +140,17 @@ class RescheduleViewModel @Inject constructor(
*/ */
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow() 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) 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 * 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 * be written; [abandoned] ticks for that refusal too, so a held copy is let
* [inFlight] would strand it until the settle timeout instead. * go by the one signal rather than by every caller remembering to.
*/ */
fun move(request: MoveRequest): Boolean { fun move(request: MoveRequest): Boolean {
if (busy || _scopePrompt.value != null) return false if (busy || _scopePrompt.value != null) {
_abandoned.value += 1
return false
}
busy = true busy = true
viewModelScope.launch { viewModelScope.launch {
val prepared = prepare(request) val prepared = prepare(request)
if (prepared == null) { if (prepared == null) {
_abandoned.value += 1
busy = false busy = false
return@launch return@launch
} }
@@ -231,6 +246,7 @@ class RescheduleViewModel @Inject constructor(
pending = null pending = null
busy = false busy = false
_scopePrompt.value = null _scopePrompt.value = null
_abandoned.value += 1
} }
/** /**
@@ -340,6 +356,7 @@ class RescheduleViewModel @Inject constructor(
val request = prepared.request val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) { if (endsBeforeItStarts(prepared, scope)) {
_outcome.value = MoveOutcome.BlockedSeriesEnd _outcome.value = MoveOutcome.BlockedSeriesEnd
_abandoned.value += 1
return return
} }
_outcome.value = try { _outcome.value = try {
@@ -375,6 +392,9 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
MoveOutcome.Failed 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.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot 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.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -66,17 +71,109 @@ private val AUTO_SCROLL_STEP = 16.dp
data class TimelineDrag( data class TimelineDrag(
val event: EventInstance, val event: EventInstance,
val date: LocalDate, 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 startMin: Int,
val endMin: Int, /** The event's new start, from [date]'s midnight — negative if it began earlier. */
val topLeftInRoot: Offset, val eventStartMin: Int,
val sizePx: IntSize, 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. */ /** What the snap haptics key off: one tick per changed slot, not per frame. */
val slot: Pair<LocalDate, Int> get() = date to startMin 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 * The timeline's live geometry, republished on every layout. Plain fields rather
@@ -134,12 +231,14 @@ class TimelineDragController {
private set private set
/** /**
* Where the settled drop came from, and which event row it belongs to. The * The event row and start instant the settled drop came from. The instance id
* instance id alone can't identify the source block for the length of the * alone can't identify the source for the length of the write: the provider
* write: the provider regenerates `Instances` rows, so a re-read carrying the * regenerates `Instances` rows, so a re-read carrying the *old* time can
* *old* time can arrive under a new instance id. * 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. */ /** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false) var isDragging: Boolean by mutableStateOf(false)
@@ -153,18 +252,42 @@ class TimelineDragController {
var settledOnGrid: Boolean by mutableStateOf(false) var settledOnGrid: Boolean by mutableStateOf(false)
private set 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 source: TimedBlock? = null
private var grab = Offset.Zero private var grab = Offset.Zero
private var pointer = 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 * 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). * as its start, since the target snaps to the grid (09:07 lifts to 09:00).
*/ */
private var originSlot: Pair<LocalDate, Int>? = null 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 source = block
this.clipOffsetMin = clipOffsetMin
settling = null settling = null
isDragging = true isDragging = true
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
@@ -182,6 +305,7 @@ class TimelineDragController {
fun cancel() { fun cancel() {
source = null source = null
clipOffsetMin = 0
isDragging = false isDragging = false
liftedInstanceId = null liftedInstanceId = null
originSlot = null originSlot = null
@@ -192,32 +316,41 @@ class TimelineDragController {
} }
/** /**
* Whether [block] is the one whose copy is in flight, and so must stay a * Whether [block] is part of the event whose copy is in flight, and so must
* ghost: the instance the finger picked up, or once dropped whatever now * stay a ghost: the instance the finger picked up, or once dropped whatever
* sits in the slot it left. * 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 (liftedInstanceId == null) return false
if (block.event.instanceId == liftedInstanceId) return true if (block.event.instanceId == liftedInstanceId) return true
val (eventId, originDate, originMin) = settledOrigin ?: return false val (eventId, start) = settledOrigin ?: return false
return block.event.eventId == eventId && return block.event.eventId == eventId && block.event.start == start
date == originDate &&
block.startMin == originMin
} }
/** /**
* What a day column now holds, so a settled drop can tell when the grid has * 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 * 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>) { fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
val landed = settling ?: return 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 -> settledOnGrid = blocks.any { block ->
block.startMin == landed.startMin && block.startMin == slice.startMin &&
( (
block.event.eventId == landed.event.eventId || 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? { fun finish(): TimelineDrop? {
val landed = drag val landed = drag
val origin = originSlot val origin = originSlot
val clipOffset = clipOffsetMin
cancel() cancel()
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
settling = landed settling = landed
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) } settledOrigin = landed.event.eventId to landed.event.start
return TimelineDrop(landed.event, landed.date, landed.startMin) settledAbandonedMark = abandonedTicks?.value ?: 0
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
} }
/** /**
@@ -271,28 +406,44 @@ class TimelineDragController {
val origin = grid.positionInRoot() val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
// Clamp the start into the target day; a tail past midnight is fine. val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt()
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) .coerceAtLeast(0)
// The event's own length, not the block's: TimedBlock.endMin is clipped val floor = dragFloorMin(clipOffsetMin, eventSpan)
// at midnight, which would draw a 22:0002:00 event as a two-hour copy. val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0) val eventStartMin = startMin - clipOffsetMin
// The column the finger is over on screen, and the day that column shows.
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
drag = TimelineDrag( drag = TimelineDrag(
event = block.event, event = block.event,
date = days[dayIndex], date = days[dayIndex],
startMin = startMin, startMin = startMin,
endMin = startMin + span, eventStartMin = eventStartMin,
topLeftInRoot = Offset( eventSpanMin = eventSpan,
x = origin.x + column * columnPx, // Bounded to the columns this timeline actually shows: a day it
y = origin.y + startMin / 60f * hourPx, // doesn't has no piece to draw. The write is unaffected.
), pieces = dragSlices(
sizePx = IntSize( eventStartMin,
(columnPx - geometry.columnGapPx).roundToInt(), eventSpan,
height.roundToInt(), 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 val density = LocalDensity.current
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() } controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() } controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller return controller
} }
/** /**
* True when [block] actually begins on [day] rather than being the tail of an * How far this block's top edge sits past its event's start, in minutes — zero
* event that started earlier. A clipped block's top edge is midnight, not the * for a block that begins on [day], and the part already run for the tail of an
* event's start, so dragging it would move the event to a time it never had. * 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 = fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
event.start.toLocalDateTime(zone).date == day 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 * True when the event began before [day] — this block is its tail, cut at
* into a spring-forward gap has no instant of its own; `toInstant` resolves it * midnight rather than starting where the event does.
* forward by the missing hour, and the block visibly settles there.
*/ */
fun TimelineDrop.startInstant(zone: TimeZone): Instant = fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone) 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 * 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 use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() val locale = currentLocale()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
val density = LocalDensity.current
val moveInFlight = moveInFlight() val moveInFlight = moveInFlight()
// Both live here rather than beside the controller: they read [drag], which // 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 // recurring drop's scope dialog is up — and then until the grid draws the
// drop itself. // drop itself.
var handingOver by remember(controller.settling) { mutableStateOf(false) } 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 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 handingOver = true
controller.handOver() controller.handOver()
delay(SETTLE_FADE_MILLIS.toLong()) delay(SETTLE_FADE_MILLIS.toLong())
@@ -428,49 +611,84 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
) )
val fill = eventFill(drag.event.color, dark, soften) val fill = eventFill(drag.event.color, dark, soften)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// An end past midnight wraps rather than saturating, so a four-hour // Read off the event rather than the held block, so grabbing either half
// event dragged to 22:00 reads "22:0002:00" and not "22:0024:00". // of an event that crosses midnight names the same hours. An end past
val endMin = if (drag.endMin > MINUTES_PER_DAY) { // midnight wraps rather than saturating, so a four-hour event dragged to
drag.endMin % MINUTES_PER_DAY // 22:00 reads "22:0002:00" and not "22:0024:00".
} else { val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
drag.endMin val rawEnd = startMin + drag.eventSpanMin
} val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}" + val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}" +
formatMinuteOfDay(endMin, use24Hour, locale) formatMinuteOfDay(endMin, use24Hour, locale)
Box( // The tallest piece carries the range: on the smallest it would be
modifier = Modifier // clipped away, which is exactly the case when a short tail is held.
// Absolute: these are root coordinates, and the direction-aware val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
// offset would mirror them across the screen in an RTL layout. drag.pieces.forEachIndexed { index, piece ->
.absoluteOffset { DragCopy(
IntOffset( topLeftInRoot = piece.topLeftInRoot,
(drag.topLeftInRoot.x - origin.x).roundToInt(), overlayOrigin = origin,
(drag.topLeftInRoot.y - origin.y).roundToInt(), sizePx = piece.sizePx,
) fill = fill,
} shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
.size( lift = lift,
width = with(density) { drag.sizePx.width.toDp() }, alpha = copyAlpha,
height = with(density) { drag.sizePx.height.toDp() }, title = title,
) // Once for the whole event: repeated, it would name it per day.
.padding(horizontal = 1.dp) label = label.takeIf { index == labelled },
.graphicsLayer { )
scaleX = 1f + 0.02f * lift }
scaleY = 1f + 0.02f * lift }
shadowElevation = 8.dp.toPx() * lift }
alpha = copyAlpha
shape = RoundedCornerShape(4.dp) /** One piece of a dragged block, floating over the calendar. */
clip = false @Composable
} private fun DragCopy(
.background(fill, RoundedCornerShape(4.dp)) topLeftInRoot: Offset,
.padding(horizontal = 4.dp, vertical = 2.dp), overlayOrigin: Offset,
) { sizePx: IntSize,
Column { fill: Color,
Text( shape: RoundedCornerShape,
text = title, lift: Float,
style = MaterialTheme.typography.labelMedium, alpha: Float,
maxLines = 1, title: String,
overflow = TextOverflow.Ellipsis, label: String?,
color = eventInk(fill, alpha = 0.85f), ) {
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(
text = label, text = label,
style = MaterialTheme.typography.labelSmall, 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.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop 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.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource 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.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors 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.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
@@ -351,7 +355,7 @@ private fun DayContent(
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = onCreateAt, onCreateAt = onCreateAt,
onDrop = { drop -> onDrop = { drop ->
val took = move?.move( move?.move(
MoveRequest( MoveRequest(
eventId = drop.event.eventId, eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -359,9 +363,6 @@ private fun DayContent(
target = MoveTarget.Start(drop.startInstant(zone)), 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) val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.background(fill, RoundedCornerShape(4.dp)) .background(fill, EventChipShape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
@@ -744,24 +745,30 @@ private fun EventBlock(
val fill = eventFill(block.event.color, dark, soften) val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge val draggable = eventDragAllowed(block.event)
// is midnight, not the event's start, so dragging it would invent a time. // The drop takes this offset back off, so a tail clipped at midnight lands
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone) // 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( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot)
},
onMove = dragController::move, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = draggable && dragController.ghosts(block, date) val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
Box( Box(
modifier = modifier modifier = modifier
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp)) .background(fill, shape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // 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.layout.positionInRoot
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlin.math.abs import kotlin.math.abs
@@ -33,6 +35,8 @@ class MonthRowGeometry(
val isRtl: Boolean, val isRtl: Boolean,
/** What this row currently draws in [lane] of column `col`, or null. */ /** What this row currently draws in [lane] of column `col`, or null. */
val chipAt: (col: Int, lane: Int) -> EventInstance?, 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]. */ /** Root top-left of the chip seated in [lane] of column [col]. */
fun seat(col: Int, lane: Int): Offset? { 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. * the day isn't in this week, or the chip went into the day's "+N" overflow.
*/ */
fun seatOf(event: EventInstance, date: LocalDate): Offset? { fun seatOf(event: EventInstance, date: LocalDate): Offset? {
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null val (col, lane) = chipSeat(days, laneCount, chipAt, chipStart, event, date) ?: return null
val lane = (0 until laneCount).firstOrNull { lane ->
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
} ?: return null
return seat(col, lane) 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 * 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 * 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 = internal fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
chip.eventId == moved.eventId || chip.title == moved.title 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. */ /** A chip in flight, in root coordinates so it can be drawn in an overlay. */
data class MonthChipDrag( data class MonthChipDrag(
@@ -134,6 +160,16 @@ class MonthDragController {
*/ */
private var undoable: MonthChipDrag? = null 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 event: EventInstance? = null
private var grabDate: LocalDate? = null private var grabDate: LocalDate? = null
private var grab = Offset.Zero private var grab = Offset.Zero
@@ -222,6 +258,7 @@ class MonthDragController {
liftedInstanceId = last.event.instanceId liftedInstanceId = last.event.instanceId
settledGhost = true settledGhost = true
settledInRoot = null settledInRoot = null
settledAbandonedMark = abandonedTicks?.value ?: 0
return true return true
} }
@@ -242,6 +279,7 @@ class MonthDragController {
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledGhost = true settledGhost = true
undoable = settling undoable = settling
settledAbandonedMark = abandonedTicks?.value ?: 0
return MonthChipDrop(landed.event, landed.grabDate, target) return MonthChipDrop(landed.event, landed.grabDate, target)
} }
@@ -298,6 +336,10 @@ class MonthDragController {
} }
@Composable @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 } 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.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.moveInFlight 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.OnUndoStarted
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics 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.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent 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.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute 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 glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>() val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
val seat = controller.settledInRoot val seat = controller.settledInRoot
LaunchedEffect(controller.settling, moveInFlight, seat) { val abandoned = abandonedMoves()
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
val landed = controller.settling val landed = controller.settling
if (landed == null || moveInFlight) return@LaunchedEffect if (landed == null || moveInFlight) return@LaunchedEffect
if (seat == null) { 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 { } else {
if (!gliding) glide.snapTo(landed.topLeftInRoot) if (!gliding) glide.snapTo(landed.topLeftInRoot)
gliding = true gliding = true
@@ -563,7 +569,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
scaleY = 1f + 0.04f * lift scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha alpha = copyAlpha
shape = RoundedCornerShape(4.dp) shape = EventChipShape
clip = false clip = false
}, },
) )
@@ -1919,6 +1925,7 @@ private fun MonthWeekRow(
laneCount = MAX_EVENT_ROWS, laneCount = MAX_EVENT_ROWS,
isRtl = isRtl, isRtl = isRtl,
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) }, 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 // How many columns the finger crossed — grabbing the middle of a
// multi-day bar shifts by what it travelled, not to where it landed. // multi-day bar shifts by what it travelled, not to where it landed.
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt() val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
val took = moveScope?.move( moveScope?.move(
MoveRequest( MoveRequest(
eventId = drop.event.eventId, eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -2318,9 +2325,6 @@ private fun monthChipDragModifier(
target = MoveTarget.ByDays(delta), 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() }, onCancel = { controller?.cancel() },
@@ -2436,12 +2440,7 @@ private fun MonthBar(
val lifted = monthDrag?.liftedInstanceId == event.instanceId || val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
monthDrag?.isSettledChip(event, days) == true monthDrag?.isSettledChip(event, days) == true
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
val shape = RoundedCornerShape( val shape = monthBarShape(continuesLeft, continuesRight)
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,
)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (ghost < 1f) Modifier.alpha(ghost) 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) 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 * 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 * 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.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -417,8 +418,11 @@ internal fun layoutCalendarWeek(
zone: TimeZone, zone: TimeZone,
): MonthWeek { ): MonthWeek {
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } } 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 -> 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 -> val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan( 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.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop 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.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource 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.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors 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.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
@@ -387,7 +391,7 @@ private fun WeekContent(
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onCreateAt = onCreateAt, onCreateAt = onCreateAt,
onDrop = { drop -> onDrop = { drop ->
val took = move?.move( move?.move(
MoveRequest( MoveRequest(
eventId = drop.event.eventId, eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -395,9 +399,6 @@ private fun WeekContent(
target = MoveTarget.Start(drop.startInstant(zone)), 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) val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(fill, RoundedCornerShape(4.dp)) .background(fill, EventChipShape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
@@ -912,24 +913,30 @@ private fun EventBlock(
val fill = eventFill(block.event.color, dark, soften) val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge val draggable = eventDragAllowed(block.event)
// is midnight, not the event's start, so dragging it would invent a time. // The drop takes this offset back off, so a tail clipped at midnight lands
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone) // 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( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot)
},
onMove = dragController::move, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = draggable && dragController.ghosts(block, date) val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp)) .background(fill, shape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // 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 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 * 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 * 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.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).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, 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. */ /** All-day events live at UTC midnights with an *exclusive* end. */
private fun allDay( private fun allDay(
from: LocalDate, from: LocalDate,
@@ -162,6 +175,43 @@ class MonthLayoutTest {
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty() 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 @Test
fun `countByDay totals bars and pills on each date`() { fun `countByDay totals bars and pills on each date`() {
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L) val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)