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,
|
move = reschedule::move,
|
||||||
inFlight = reschedule.inFlight,
|
inFlight = reschedule.inFlight,
|
||||||
undoStarted = reschedule.undoStarted,
|
undoStarted = reschedule.undoStarted,
|
||||||
|
abandoned = reschedule.abandoned,
|
||||||
edit = onEditEvent,
|
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>,
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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:00–02: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:00–02:00" and not "22:00–24: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:00–02:00" and not "22:00–24: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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user