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:
@@ -322,6 +322,7 @@ fun CalendarHost(
|
||||
move = reschedule::move,
|
||||
inFlight = reschedule.inFlight,
|
||||
undoStarted = reschedule.undoStarted,
|
||||
abandoned = reschedule.abandoned,
|
||||
edit = onEditEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:00–02:00 event as a two-hour copy.
|
||||
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
|
||||
// The column the finger is over on screen, and the day that column shows.
|
||||
val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt()
|
||||
.coerceAtLeast(0)
|
||||
val floor = dragFloorMin(clipOffsetMin, eventSpan)
|
||||
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||
val eventStartMin = startMin - clipOffsetMin
|
||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||
drag = TimelineDrag(
|
||||
event = block.event,
|
||||
date = days[dayIndex],
|
||||
startMin = startMin,
|
||||
endMin = startMin + span,
|
||||
topLeftInRoot = Offset(
|
||||
x = origin.x + column * columnPx,
|
||||
y = origin.y + startMin / 60f * hourPx,
|
||||
),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
height.roundToInt(),
|
||||
),
|
||||
eventStartMin = eventStartMin,
|
||||
eventSpanMin = eventSpan,
|
||||
// Bounded to the columns this timeline actually shows: a day it
|
||||
// doesn't has no piece to draw. The write is unaffected.
|
||||
pieces = dragSlices(
|
||||
eventStartMin,
|
||||
eventSpan,
|
||||
within = -dayIndex..days.lastIndex - dayIndex,
|
||||
).map { slice ->
|
||||
val day = dayIndex + slice.dayOffset
|
||||
val col = if (geometry.isRtl) days.lastIndex - day else day
|
||||
TimelineDragPiece(
|
||||
topLeftInRoot = Offset(
|
||||
x = origin.x + col * columnPx,
|
||||
y = origin.y + slice.startMin / 60f * hourPx,
|
||||
),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
maxOf(
|
||||
slice.spanMin / 60f * hourPx,
|
||||
MIN_EVENT_FRACTION * hourPx,
|
||||
).roundToInt(),
|
||||
),
|
||||
continuesBefore = slice.continuesBefore,
|
||||
continuesAfter = slice.continuesAfter,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -334,24 +485,52 @@ fun rememberTimelineDragController(): TimelineDragController {
|
||||
val density = LocalDensity.current
|
||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||
return controller
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [block] actually begins on [day] rather than being the tail of an
|
||||
* event that started earlier. A clipped block's top edge is midnight, not the
|
||||
* event's start, so dragging it would move the event to a time it never had.
|
||||
* How far this block's top edge sits past its event's start, in minutes — zero
|
||||
* for a block that begins on [day], and the part already run for the tail of an
|
||||
* event that started earlier, whose top edge is midnight rather than the event's
|
||||
* start. Dragging such a tail moves the event by where *its own* top edge lands,
|
||||
* so the offset has to come back off at the drop (#253).
|
||||
*/
|
||||
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
|
||||
event.start.toLocalDateTime(zone).date == day
|
||||
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
|
||||
val start = event.start.toLocalDateTime(zone)
|
||||
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
|
||||
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
|
||||
return minutes.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
|
||||
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
|
||||
* forward by the missing hour, and the block visibly settles there.
|
||||
* True when the event began before [day] — this block is its tail, cut at
|
||||
* midnight rather than starting where the event does.
|
||||
*/
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
|
||||
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
|
||||
event.start < day.atStartOfDayIn(zone)
|
||||
|
||||
/** True when the event runs past [day]'s midnight, so this block is cut there. */
|
||||
fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
|
||||
event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
|
||||
/**
|
||||
* The instant a drop asks for — the *event's* new start, so the tail of an
|
||||
* overnight event drops back onto the day its own start belongs to. Read in the
|
||||
* zone the timeline is drawn in. A drop into a spring-forward gap has no instant
|
||||
* of its own; `toInstant` resolves it forward by the missing hour, and the block
|
||||
* visibly settles there.
|
||||
*/
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
|
||||
// Wall clock throughout, and past this day at either end: a block's top edge
|
||||
// can be dragged above its midnight and a tail's below the next one, so the
|
||||
// start is resolved as a day plus a minute of it rather than as an offset
|
||||
// from an instant — which would shift by an hour across a DST boundary.
|
||||
val fromMidnight = startMin - clipOffsetMin
|
||||
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
||||
val minute = fromMidnight.mod(MINUTES_PER_DAY)
|
||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
||||
}
|
||||
|
||||
/**
|
||||
* A beat after the grid draws the drop, so its own block is under way before the
|
||||
@@ -384,7 +563,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val density = LocalDensity.current
|
||||
val moveInFlight = moveInFlight()
|
||||
|
||||
// Both live here rather than beside the controller: they read [drag], which
|
||||
@@ -397,9 +575,14 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||
// drop itself.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
|
||||
val abandoned = abandonedMoves()
|
||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
|
||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
// Past the mark this drop was let go on, it was abandoned: nothing will
|
||||
// land and there is nothing to wait for.
|
||||
if (abandoned == controller.settledAbandonedMark) {
|
||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
}
|
||||
handingOver = true
|
||||
controller.handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
@@ -428,49 +611,84 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
)
|
||||
val fill = eventFill(drag.event.color, dark, soften)
|
||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
// An end past midnight wraps rather than saturating, so a four-hour
|
||||
// event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
||||
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
|
||||
drag.endMin % MINUTES_PER_DAY
|
||||
} else {
|
||||
drag.endMin
|
||||
}
|
||||
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
||||
// Read off the event rather than the held block, so grabbing either half
|
||||
// of an event that crosses midnight names the same hours. An end past
|
||||
// midnight wraps rather than saturating, so a four-hour event dragged to
|
||||
// 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
||||
val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
|
||||
val rawEnd = startMin + drag.eventSpanMin
|
||||
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
|
||||
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" +
|
||||
formatMinuteOfDay(endMin, use24Hour, locale)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
IntOffset(
|
||||
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
||||
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.size(
|
||||
width = with(density) { drag.sizePx.width.toDp() },
|
||||
height = with(density) { drag.sizePx.height.toDp() },
|
||||
)
|
||||
.padding(horizontal = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.02f * lift
|
||||
scaleY = 1f + 0.02f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
alpha = copyAlpha
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
clip = false
|
||||
}
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
// The tallest piece carries the range: on the smallest it would be
|
||||
// clipped away, which is exactly the case when a short tail is held.
|
||||
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
|
||||
drag.pieces.forEachIndexed { index, piece ->
|
||||
DragCopy(
|
||||
topLeftInRoot = piece.topLeftInRoot,
|
||||
overlayOrigin = origin,
|
||||
sizePx = piece.sizePx,
|
||||
fill = fill,
|
||||
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
|
||||
lift = lift,
|
||||
alpha = copyAlpha,
|
||||
title = title,
|
||||
// Once for the whole event: repeated, it would name it per day.
|
||||
label = label.takeIf { index == labelled },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One piece of a dragged block, floating over the calendar. */
|
||||
@Composable
|
||||
private fun DragCopy(
|
||||
topLeftInRoot: Offset,
|
||||
overlayOrigin: Offset,
|
||||
sizePx: IntSize,
|
||||
fill: Color,
|
||||
shape: RoundedCornerShape,
|
||||
lift: Float,
|
||||
alpha: Float,
|
||||
title: String,
|
||||
label: String?,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
IntOffset(
|
||||
(topLeftInRoot.x - overlayOrigin.x).roundToInt(),
|
||||
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.size(
|
||||
width = with(density) { sizePx.width.toDp() },
|
||||
height = with(density) { sizePx.height.toDp() },
|
||||
)
|
||||
.padding(horizontal = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.02f * lift
|
||||
scaleY = 1f + 0.02f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
this.alpha = alpha
|
||||
this.shape = shape
|
||||
clip = false
|
||||
}
|
||||
.background(fill, shape)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (label != null) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Where a week row draws a moved chip — what the settling copy glides onto
|
||||
* (#68, #253).
|
||||
*/
|
||||
class ChipSeatTest {
|
||||
|
||||
private val monday = LocalDate(2026, 6, 8)
|
||||
private val days = (0..6).map { monday.plus(it, DateTimeUnit.DAY) }
|
||||
|
||||
private fun event(id: Long, title: String = "E") = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = monday.atTime(9, 0).toInstant(TimeZone.UTC),
|
||||
end = monday.atTime(10, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = false,
|
||||
color = 0xFF112233.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A grid holding [seated] in lane 0 as one bar across [cols], and nothing
|
||||
* else — the seating a [MonthWeek] gives a span.
|
||||
*/
|
||||
private fun row(seated: EventInstance, cols: IntRange) = MonthWeek(
|
||||
days = days,
|
||||
spans = listOf(
|
||||
MonthSpan(
|
||||
event = seated,
|
||||
startCol = cols.first,
|
||||
endCol = cols.last,
|
||||
lane = 0,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
),
|
||||
),
|
||||
timedByDay = emptyMap(),
|
||||
countByDay = emptyMap(),
|
||||
)
|
||||
|
||||
/** A grid holding one single-day pill per day of [cols], all of [seated]. */
|
||||
private fun pills(seated: EventInstance, cols: IntRange) = MonthWeek(
|
||||
days = days,
|
||||
spans = emptyList(),
|
||||
timedByDay = cols.associate { days[it] to listOf(seated) },
|
||||
countByDay = emptyMap(),
|
||||
)
|
||||
|
||||
private fun seat(week: MonthWeek, event: EventInstance, date: LocalDate) = chipSeat(
|
||||
days = days,
|
||||
laneCount = 3,
|
||||
chipAt = { col, lane -> week.chipAt(col, lane, 3) },
|
||||
chipStart = { col, lane -> week.chipStartCol(col, lane) },
|
||||
event = event,
|
||||
date = date,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a single-day chip is seated in its own column`() {
|
||||
val ev = event(1L)
|
||||
assertThat(seat(row(ev, 3..3), ev, days[3])).isEqualTo(3 to 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar is seated from its first column, not the day it was dropped on`() {
|
||||
val ev = event(1L)
|
||||
// Covers Wed–Thu; dropped on the Thursday it now runs into.
|
||||
assertThat(seat(row(ev, 2..3), ev, days[3])).isEqualTo(2 to 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar carried in from the previous week is seated at column zero`() {
|
||||
val ev = event(1L)
|
||||
assertThat(seat(row(ev, 0..4), ev, days[4])).isEqualTo(0 to 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row that does not hold the event seats nothing`() {
|
||||
val ev = event(1L)
|
||||
assertThat(seat(row(ev, 2..3), ev, days[5])).isNull()
|
||||
assertThat(seat(row(ev, 2..3), ev, LocalDate(2026, 7, 1))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a re-read chip is matched by title once its event id has changed`() {
|
||||
// A single-occurrence move writes a new event id; the title survives.
|
||||
val moved = event(1L, title = "Standup")
|
||||
val reRead = event(9L, title = "Standup")
|
||||
assertThat(seat(row(reRead, 3..3), moved, days[3])).isEqualTo(3 to 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two untitled events are not the same event`() {
|
||||
val moved = event(1L, title = "")
|
||||
val other = event(2L, title = "")
|
||||
assertThat(isSameEvent(other, moved)).isFalse()
|
||||
assertThat(seat(row(other, 3..3), moved, days[3])).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a daily series seats each occurrence in its own column`() {
|
||||
// Every column holds an occurrence of the same event id, so a walk left
|
||||
// by identity would run to the start of the row (#253).
|
||||
val ev = event(1L, title = "Standup")
|
||||
assertThat(seat(pills(ev, 0..6), ev, days[4])).isEqualTo(4 to 0)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user