Resize a selected event from handles on its ends (#141)
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** The handle's touch target, centred on the edge it moves. */
|
||||
val ResizeHandleTarget = 40.dp
|
||||
|
||||
private val HANDLE_DOT = 12.dp
|
||||
private val HANDLE_RING = 2.dp
|
||||
|
||||
/**
|
||||
* A grip on one edge of a selected event (#141). It takes the gesture from the
|
||||
* first movement, with no hold — the event is already selected — and hands the
|
||||
* finger on in root coordinates. A tap is swallowed rather than falling through
|
||||
* to the slot under it.
|
||||
*
|
||||
* No semantics of its own: TalkBack reaches the same change through the event's
|
||||
* edit action.
|
||||
*/
|
||||
@Composable
|
||||
fun ResizeHandle(
|
||||
color: Color,
|
||||
onStart: (pointerInRoot: Offset) -> Unit,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onEnd: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val currentStart by rememberUpdatedState(onStart)
|
||||
val currentMove by rememberUpdatedState(onMove)
|
||||
val currentEnd by rememberUpdatedState(onEnd)
|
||||
val currentCancel by rememberUpdatedState(onCancel)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.size(ResizeHandleTarget)
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { coordinates[0] = it }
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
down.consume()
|
||||
var started = false
|
||||
var ended = false
|
||||
try {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
// Claimed by an ancestor that outranks us — the pinch.
|
||||
if (change.isConsumed) break
|
||||
if (event.changes.count { it.pressed } > 1) break
|
||||
change.consume()
|
||||
if (!change.pressed) {
|
||||
ended = started
|
||||
break
|
||||
}
|
||||
val root = coordinates[0]?.takeIf { it.isAttached }
|
||||
?.localToRoot(change.position) ?: break
|
||||
if (started) {
|
||||
currentMove(root)
|
||||
} else {
|
||||
started = true
|
||||
currentStart(root)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
when {
|
||||
ended -> currentEnd()
|
||||
started -> currentCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(HANDLE_DOT)
|
||||
.background(color, CircleShape)
|
||||
.border(HANDLE_RING, MaterialTheme.colorScheme.surface, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -100,8 +100,11 @@ data class TimelineDrag(
|
||||
*/
|
||||
val edgeOnly: Boolean = false,
|
||||
) {
|
||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
||||
/**
|
||||
* What the snap haptics key off: one tick per changed slot, not per frame.
|
||||
* Carries the length, which is all a bottom-edge resize changes.
|
||||
*/
|
||||
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,6 +259,25 @@ data class TimelineDrop(
|
||||
val clipOffsetMin: Int = 0,
|
||||
)
|
||||
|
||||
/** Which end of an event a resize handle moves (#141). */
|
||||
enum class ResizeEdge { Top, Bottom }
|
||||
|
||||
/** Where a finished resize asks its event to begin and end. */
|
||||
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
|
||||
|
||||
/**
|
||||
* The span after dragging [edge] to [edgeMin], all in minutes from one midnight.
|
||||
* The edge snaps to the grid, and never comes closer than one snap step to the
|
||||
* other end — an event can't be resized away to nothing, or turned inside out.
|
||||
*/
|
||||
internal fun resizedSpan(edge: ResizeEdge, startMin: Int, endMin: Int, edgeMin: Float): Pair<Int, Int> {
|
||||
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
return when (edge) {
|
||||
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
|
||||
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline's live geometry, republished on every layout. Plain fields rather
|
||||
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
||||
@@ -328,6 +350,9 @@ class TimelineDragController {
|
||||
*/
|
||||
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
||||
|
||||
/** Where a finished resize is written — set by the screen that hosts this timeline. */
|
||||
var onResize: (TimelineResizeDrop) -> Unit = {}
|
||||
|
||||
/** The selected event, if any — see [TimelineSelection]. */
|
||||
var selection: TimelineSelection? by mutableStateOf(null)
|
||||
private set
|
||||
@@ -392,7 +417,18 @@ class TimelineDragController {
|
||||
* The slot the block already occupied when it was picked up — not the same
|
||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||
*/
|
||||
private var originSlot: Pair<LocalDate, Int>? = null
|
||||
private var originSlot: Triple<LocalDate, Int, Int>? = null
|
||||
|
||||
/** The edge a resize is dragging, or null for a plain drag (#141). */
|
||||
private var resizeEdge: ResizeEdge? = null
|
||||
|
||||
/** The day the resized event starts on; its edges are minutes from that midnight. */
|
||||
private var resizeDay: LocalDate? = null
|
||||
private var resizeStartMin = 0
|
||||
private var resizeEndMin = 0
|
||||
|
||||
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||
private var resizeGrabMin = 0f
|
||||
|
||||
fun begin(
|
||||
block: TimedBlock,
|
||||
@@ -423,6 +459,8 @@ class TimelineDragController {
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
resizeEdge = null
|
||||
resizeDay = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
titleLines = 0
|
||||
@@ -465,6 +503,8 @@ class TimelineDragController {
|
||||
.firstOrNull() ?: return
|
||||
settledOnGrid = blocks.any { block ->
|
||||
block.startMin == slice.startMin &&
|
||||
// The end too: a bottom-edge resize keeps the start.
|
||||
block.endMin == slice.startMin + slice.spanMin &&
|
||||
(
|
||||
block.event.eventId == landed.event.eventId ||
|
||||
(
|
||||
@@ -497,6 +537,114 @@ class TimelineDragController {
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grab one edge of [block] to resize it (#141). The edge follows the finger
|
||||
* across days as a drag does; the other stays where it is.
|
||||
*/
|
||||
fun beginResize(
|
||||
block: TimedBlock,
|
||||
edge: ResizeEdge,
|
||||
zone: TimeZone,
|
||||
titleLines: Int,
|
||||
pointerInRoot: Offset,
|
||||
) {
|
||||
val start = block.event.start.toLocalDateTime(zone)
|
||||
source = block
|
||||
resizeEdge = edge
|
||||
resizeDay = start.date
|
||||
resizeStartMin = start.time.toSecondOfDay() / 60
|
||||
resizeEndMin = resizeStartMin +
|
||||
(block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
pointer = pointerInRoot
|
||||
resizeGrabMin = 0f
|
||||
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
|
||||
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
}
|
||||
|
||||
/**
|
||||
* End a resize, handing back the event's new span — null when it never
|
||||
* resolved or came back to the length it started at. Settles as [finish] does.
|
||||
*/
|
||||
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
|
||||
val landed = drag
|
||||
val origin = originSlot
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
return TimelineResizeDrop(
|
||||
event = landed.event,
|
||||
start = wallClockInstant(landed.date, landed.eventStartMin, zone),
|
||||
end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the finger is, in minutes from the resized event's start day —
|
||||
* past 1440 in a later column, negative in an earlier one.
|
||||
*/
|
||||
private fun pointerResizeMinutes(): Float? {
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
|
||||
val day = resizeDay ?: return null
|
||||
val hourPx = geometry.hourPx
|
||||
val columnPx = geometry.columnWidthPx
|
||||
val days = geometry.days
|
||||
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
|
||||
val origin = grid.positionInRoot()
|
||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
|
||||
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
|
||||
}
|
||||
|
||||
private fun recomputeResize(edge: ResizeEdge) {
|
||||
val block = source ?: return
|
||||
val day = resizeDay ?: return
|
||||
val raw = pointerResizeMinutes() ?: return
|
||||
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, raw - resizeGrabMin)
|
||||
val days = geometry.days
|
||||
val hourPx = geometry.hourPx
|
||||
val columnPx = geometry.columnWidthPx
|
||||
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
|
||||
// Which column the start day sits in, possibly off either end of the view.
|
||||
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
|
||||
val visible = -startIndex..days.lastIndex - startIndex
|
||||
val slices = dragSlices(start, end - start, within = visible)
|
||||
drag = TimelineDrag(
|
||||
event = block.event,
|
||||
date = day,
|
||||
startMin = start,
|
||||
eventStartMin = start,
|
||||
eventSpanMin = end - start,
|
||||
titleLines = titleLines,
|
||||
pieces = slices.map { slice ->
|
||||
val dayIndex = startIndex + slice.dayOffset
|
||||
val col = if (geometry.isRtl) days.lastIndex - dayIndex else dayIndex
|
||||
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
|
||||
TimelineDragPiece(
|
||||
topLeftInRoot = Offset(x = origin.x + col * columnPx, y = origin.y + bounds.top),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
bounds.height.roundToInt(),
|
||||
),
|
||||
continuesBefore = slice.continuesBefore,
|
||||
continuesAfter = slice.continuesAfter,
|
||||
)
|
||||
},
|
||||
edgeOnly = slices.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ghosting the source — the grid draws the drop itself by now — while
|
||||
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
||||
@@ -520,6 +668,10 @@ class TimelineDragController {
|
||||
* no move events while auto-scroll walks the content underneath it.
|
||||
*/
|
||||
fun recompute() {
|
||||
resizeEdge?.let {
|
||||
recomputeResize(it)
|
||||
return
|
||||
}
|
||||
val block = source ?: return
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||
val hourPx = geometry.hourPx
|
||||
@@ -647,14 +799,18 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
|
||||
* 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)
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
wallClockInstant(date, startMin - clipOffsetMin, zone)
|
||||
|
||||
/**
|
||||
* [minutes] past [date]'s midnight as an instant, read as wall clock in [zone].
|
||||
* 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; a block can be
|
||||
* dragged above its midnight and past the next one, so either end may overflow.
|
||||
*/
|
||||
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant {
|
||||
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
||||
val minute = minutes.mod(MINUTES_PER_DAY)
|
||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,14 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickUp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
|
||||
import de.jeanlucmakiola.calendula.ui.common.ResizeHandle
|
||||
import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.PublishSelection
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventSelectAllowed
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectionLift
|
||||
@@ -537,6 +544,22 @@ private fun WeekContent(
|
||||
)
|
||||
}
|
||||
|
||||
// A resize keeps its event selected, at its new length (#141).
|
||||
dragController.onResize = { drop: TimelineResizeDrop ->
|
||||
dragController.select(
|
||||
drop.event.copy(start = drop.start, end = drop.end),
|
||||
drop.start.toLocalDateTime(zone).date,
|
||||
)
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Span(drop.start, drop.end),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// A long-pressed event stays selected (#141) until something else is touched:
|
||||
// back, another page, a view switch, or any tap.
|
||||
val selection = dragController.selection
|
||||
@@ -1557,44 +1580,89 @@ private fun EventBlock(
|
||||
val lifted = draggable && dragController.ghosts(block)
|
||||
val ghost = ghostAlpha(lifted)
|
||||
val raise = selectionLift(selected)
|
||||
// Handles on the selected event's own ends — not on a cut at midnight — while
|
||||
// nothing is in flight. A read-only event is selected but keeps its length.
|
||||
val showHandles = selected && draggable && !lifted && !dragController.isDragging
|
||||
Box(
|
||||
modifier = (if (dimmed && !selected) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.zIndex(if (selected) 1f else 0f)
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier)
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.clickable(onClick = onClick)
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier),
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = titleMaxLines,
|
||||
color = paint.titleInk,
|
||||
textDecoration = paint.decoration,
|
||||
fontWeight = paint.titleWeight,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.clickable(onClick = onClick)
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = titleMaxLines,
|
||||
color = paint.titleInk,
|
||||
textDecoration = paint.decoration,
|
||||
fontWeight = paint.titleWeight,
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
BlockTimeLabel(
|
||||
label = shownTime,
|
||||
color = paint.secondaryInk,
|
||||
maxLines = timeMaxLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showHandles) {
|
||||
val accent = eventAccent(block.event.color, dark, LocalSoftenColors.current)
|
||||
val handleLines = if (showTitle) titleMaxLines else 0
|
||||
val begin = { edge: ResizeEdge ->
|
||||
{ pointer: Offset ->
|
||||
dragController.beginResize(block, edge, zone, handleLines, pointer)
|
||||
}
|
||||
}
|
||||
val end = { dragController.finishResize(zone)?.let(dragController.onResize) }
|
||||
if (!cuts.top) {
|
||||
ResizeHandle(
|
||||
color = accent,
|
||||
onStart = begin(ResizeEdge.Top),
|
||||
onMove = dragController::move,
|
||||
onEnd = { end() },
|
||||
onCancel = dragController::cancel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.offset(x = HANDLE_INSET - ResizeHandleTarget / 2, y = -ResizeHandleTarget / 2),
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
BlockTimeLabel(
|
||||
label = shownTime,
|
||||
color = paint.secondaryInk,
|
||||
maxLines = timeMaxLines,
|
||||
if (!cuts.bottom) {
|
||||
ResizeHandle(
|
||||
color = accent,
|
||||
onStart = begin(ResizeEdge.Bottom),
|
||||
onMove = dragController::move,
|
||||
onEnd = { end() },
|
||||
onCancel = dragController::cancel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = ResizeHandleTarget / 2 - HANDLE_INSET, y = ResizeHandleTarget / 2),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How far in from the block's side a resize handle's centre sits. */
|
||||
private val HANDLE_INSET = 16.dp
|
||||
|
||||
private val SKELETON_HEADER_HEIGHT = 36.dp
|
||||
private val SKELETON_HEADER_PADDING = 8.dp
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** Where a dragged resize handle leaves an event's ends (#141). */
|
||||
class ResizedSpanTest {
|
||||
|
||||
// 09:00–10:00, in minutes from its day's midnight.
|
||||
private val start = 9 * 60
|
||||
private val end = 10 * 60
|
||||
|
||||
@Test
|
||||
fun `the bottom edge snaps and leaves the start alone`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 11 * 60 + 8f))
|
||||
.isEqualTo(start to 11 * 60 + 15)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the top edge snaps and leaves the end alone`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Top, start, end, 8 * 60 - 7f))
|
||||
.isEqualTo(8 * 60 to end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edge stops one snap step short of the other end`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 7 * 60f))
|
||||
.isEqualTo(start to start + DRAG_SNAP_MINUTES)
|
||||
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f))
|
||||
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bottom edge runs on past midnight`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 24 * 60 + 90f))
|
||||
.isEqualTo(start to 24 * 60 + 90)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the top edge runs back into the day before`() {
|
||||
assertThat(resizedSpan(ResizeEdge.Top, start, end, -60f))
|
||||
.isEqualTo(-60 to end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wall clock minutes past either midnight land on the neighbouring day`() {
|
||||
val zone = TimeZone.of("Europe/Berlin")
|
||||
val day = LocalDate(2026, 10, 9)
|
||||
assertThat(wallClockInstant(day, 24 * 60 + 30, zone))
|
||||
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 10), LocalTime(0, 30)).toInstant(zone))
|
||||
assertThat(wallClockInstant(day, -30, zone))
|
||||
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 8), LocalTime(23, 30)).toInstant(zone))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user