Keep resize handles alive mid-gesture and follow a moved occurrence (#141)

This commit is contained in:
2026-10-09 11:31:35 +02:00
parent c8d3d21ba3
commit 38a860aafd
2 changed files with 52 additions and 13 deletions
@@ -109,9 +109,10 @@ data class TimelineDrag(
/**
* The event a long press left selected (#141): lifted on the grid, with the
* action bar up. [date] is the column the press landed in.
* action bar up. [date] is the column the press landed in. [moved] holds from a
* move or resize of it until the grid shows where it landed.
*/
data class TimelineSelection(val event: EventInstance, val date: LocalDate)
data class TimelineSelection(val event: EventInstance, val date: LocalDate, val moved: Boolean = false)
/**
* One day column's share of a dragged event, ready to draw. Both halves of an
@@ -357,8 +358,8 @@ class TimelineDragController {
var selection: TimelineSelection? by mutableStateOf(null)
private set
fun select(event: EventInstance, date: LocalDate) {
selection = TimelineSelection(event, date)
fun select(event: EventInstance, date: LocalDate, moved: Boolean = false) {
selection = TimelineSelection(event, date, moved)
}
fun deselect() {
@@ -368,12 +369,38 @@ class TimelineDragController {
/**
* Whether [block] is the selected event. Matched on the series and start too,
* since the provider regenerates `Instances` rows and a re-read can bring the
* same occurrence back under a new instance id.
* same occurrence back under a new instance id — and, just after a move, on
* what the event says, since moving one occurrence of a series writes it out
* as an exception row with an id of its own. Only then: two copies of one
* event would otherwise both match, and take the selection back and forth.
* A blank title matches nothing that way.
*/
fun isSelected(block: TimedBlock): Boolean {
val selected = selection?.event ?: return false
return block.event.instanceId == selected.instanceId ||
(block.event.eventId == selected.eventId && block.event.start == selected.start)
val current = selection ?: return false
val selected = current.event
val event = block.event
return event.instanceId == selected.instanceId ||
(event.eventId == selected.eventId && event.start == selected.start) ||
(
current.moved &&
event.calendarId == selected.calendarId &&
event.title.isNotBlank() &&
event.title == selected.title &&
event.start == selected.start &&
event.end == selected.end
)
}
/**
* Take over [block]'s event as the selection once the grid shows it, so the
* bar acts on the row that is there now — an exception written by a move has
* its own id, and an abandoned move left the original times standing.
*/
fun adoptSelection(block: TimedBlock) {
val current = selection ?: return
if (current.event != block.event || current.moved) {
selection = current.copy(event = block.event, moved = false)
}
}
/** Whether a finger is on a block right now — [settling] is not dragging. */
@@ -532,6 +532,7 @@ private fun WeekContent(
dragController.select(
drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start)),
start.toLocalDateTime(zone).date,
moved = true,
)
}
move?.move(
@@ -549,6 +550,7 @@ private fun WeekContent(
dragController.select(
drop.event.copy(start = drop.start, end = drop.end),
drop.start.toLocalDateTime(zone).date,
moved = true,
)
move?.move(
MoveRequest(
@@ -1580,9 +1582,16 @@ 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
// Not while the block is waiting on its own write: until then it still
// shows the times from before it.
LaunchedEffect(selected, block.event, lifted) {
if (selected && !lifted) dragController.adoptSelection(block)
}
// Handles on the selected event's own ends — not on a cut at midnight. They
// stay composed for the whole selection and only hide while something is in
// flight: removing them would cancel the resize they started.
val showHandles = selected && draggable
val handlesShown = !lifted && !dragController.isDragging
Box(
modifier = (if (dimmed && !selected) modifier.alpha(EventDimAlpha) else modifier)
.zIndex(if (selected) 1f else 0f)
@@ -1632,6 +1641,7 @@ private fun EventBlock(
}
}
val end = { dragController.finishResize(zone)?.let(dragController.onResize) }
val handleAlpha = if (handlesShown) Modifier else Modifier.alpha(0f)
if (!cuts.top) {
ResizeHandle(
color = accent,
@@ -1641,7 +1651,8 @@ private fun EventBlock(
onCancel = dragController::cancel,
modifier = Modifier
.align(Alignment.TopStart)
.offset(x = HANDLE_INSET - ResizeHandleTarget / 2, y = -ResizeHandleTarget / 2),
.offset(x = HANDLE_INSET - ResizeHandleTarget / 2, y = -ResizeHandleTarget / 2)
.then(handleAlpha),
)
}
if (!cuts.bottom) {
@@ -1653,7 +1664,8 @@ private fun EventBlock(
onCancel = dragController::cancel,
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = ResizeHandleTarget / 2 - HANDLE_INSET, y = ResizeHandleTarget / 2),
.offset(x = ResizeHandleTarget / 2 - HANDLE_INSET, y = ResizeHandleTarget / 2)
.then(handleAlpha),
)
}
}