Fix second review round on selection, resize and the FAB (#141)

This commit is contained in:
2026-10-09 19:09:06 +02:00
parent f50095ab1e
commit 97dbdcad0c
8 changed files with 148 additions and 71 deletions
@@ -90,8 +90,6 @@ class ChromeHost {
var selectionCompact by mutableStateOf(false)
internal set
/** Whether the selection's action bar is showing — it steps aside mid-gesture. */
val selectionBarShown: Boolean get() = selection != null && !selectionBusy
var actions: ScreenActions? = null
private set
@@ -124,7 +122,12 @@ fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onD
val current = slot.current
DisposableEffect(host, selection, current) {
if (current) host.selection = selection
onDispose { if (host.selection === selection) host.selection = null }
onDispose {
if (host.selection === selection) host.selection = null
// Left mid-drag, nothing else would clear these for the other views.
host.selectionBusy = false
host.selectionCompact = false
}
}
if (current) {
SideEffect {
@@ -24,6 +24,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -36,7 +37,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
val FabSize = 56.dp
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
@Stable
class FabSelection(
val expanded: Boolean,
val onToggle: () -> Unit,
@@ -44,8 +49,6 @@ class FabSelection(
)
/** The standard FAB's size, which the row it sits in is held to. */
private val FAB_SIZE = 56.dp
/**
* The FAB stack shared by the three calendar views: a persistent "+" to
@@ -80,10 +83,10 @@ fun CalendarFabColumn(
}
// Held past the selection clearing, so the bar has something to fold away.
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
selection?.let { heldBar.value = it.bar }
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
// Held at the FAB's height so the taller bar can't shift the FAB.
Row(
modifier = Modifier.height(FAB_SIZE),
modifier = Modifier.height(FabSize),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -106,16 +106,17 @@ fun EventMoveHost(
// tapped in the last moments of the window leaves the outcome at Moved on
// purpose, and this timer would otherwise fire mid-undo and close the chip
// just before the same chip has to say "undone".
LaunchedEffect(actionFailure) {
if (actionFailure == null) return@LaunchedEffect
delay(CHIP_MILLIS)
onActionFailureShown()
}
LaunchedEffect(outcome, undoTick, writeInFlight) {
if (outcome == null || writeInFlight) return@LaunchedEffect
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
viewModel.consumeOutcome()
}
// A failure waits for the chip to be free, then has its own turn.
LaunchedEffect(actionFailure, outcome == null) {
if (actionFailure == null || outcome != null) return@LaunchedEffect
delay(CHIP_MILLIS)
onActionFailureShown()
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val chipMaxWidth = maxWidth - FAB_BAND
@@ -38,7 +38,7 @@ fun EventSelectionBar(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shadowElevation = 6.dp,
modifier = modifier.height(56.dp),
modifier = modifier.height(FabSize),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -17,7 +18,7 @@ import de.jeanlucmakiola.floret.components.SnackChipMargin
import kotlinx.coroutines.launch
/** The bottom band the selection bar and FAB cover, margin included. */
val SelectionBarClearance = 56.dp + SnackChipMargin
val SelectionBarClearance = FabSize + SnackChipMargin
/** The FAB's role while an event is selected (#141): its action bar's toggle. Null otherwise. */
@Composable
@@ -40,45 +41,53 @@ fun rememberFabSelection(
// Folded while a chip is up, which then sits beside the FAB as usual.
val expanded = !chipShown && (chosen ?: !host.selectionCompact)
val currentEdit by rememberUpdatedState(onEdit)
val currentDuplicate by rememberUpdatedState(onDuplicate)
val currentDismissChip by rememberUpdatedState(onDismissChip)
val currentWrite by rememberUpdatedState(requireWrite)
val selection = host.selection ?: return null
val event = selection.event
val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true
return FabSelection(
expanded = expanded,
onToggle = {
if (chipShown) onDismissChip()
chosen = !expanded
},
bar = {
EventSelectionBar(
canEdit = canEdit,
canDelete = canEdit && !calendar.isManaged,
onEdit = {
requireWrite {
selection.dismiss()
onEdit(event)
}
},
onDuplicate = {
requireWrite {
scope.launch {
val form = viewModel.duplicateForm(event) ?: return@launch
val canDelete = canEdit && calendar?.isManaged == false
return remember(selection, expanded, chipShown, canEdit, canDelete) {
FabSelection(
expanded = expanded,
onToggle = {
if (chipShown) currentDismissChip()
chosen = !expanded
},
bar = {
EventSelectionBar(
canEdit = canEdit,
canDelete = canDelete,
onEdit = {
currentWrite {
selection.dismiss()
onDuplicate(form)
currentEdit(event)
}
}
},
onShare = {
scope.launch {
val uri = viewModel.shareUri(event) ?: return@launch
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
}
},
onDelete = { requireWrite { viewModel.requestDelete(event) } },
)
},
)
},
onDuplicate = {
currentWrite {
scope.launch {
val form = viewModel.duplicateForm(event) ?: return@launch
selection.dismiss()
currentDuplicate(form)
}
}
},
onShare = {
scope.launch {
val uri = viewModel.shareUri(event) ?: return@launch
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
}
},
onDelete = { currentWrite { viewModel.requestDelete(event) } },
)
},
)
}
}
/** What a failed action on a selected event says, for the move host's chip to show (#141). */
@@ -21,7 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -38,6 +38,7 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@@ -390,9 +391,6 @@ 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
@@ -407,12 +405,26 @@ class TimelineDragController {
selectionCovered = false
}
/** How many blocks on screen show the selected event; none for a while ends the selection. */
var selectedBlocks: Int by mutableIntStateOf(0)
private set
/** The events of the blocks on screen that show the selection; none for a while ends it. */
val selectedCandidates = mutableStateListOf<EventInstance>()
fun noteSelectedBlock(shown: Boolean) {
selectedBlocks += if (shown) 1 else -1
fun noteSelectedBlock(event: EventInstance, shown: Boolean) {
if (shown) selectedCandidates.add(event) else selectedCandidates.remove(event)
}
/**
* Once a move of the selected event has settled, point the selection at the
* row it landed as. A new event id wins: a single moved occurrence is written
* as an exception, and another occurrence of its series may share its time.
*/
fun settleMovedSelection() {
val current = selection?.takeIf { it.moved } ?: return
if (isDragging || settling != null) return
val landed = selectedCandidates.filter { it.instanceId != current.event.instanceId }
// Only the original row left: the move was abandoned, and it still stands.
val chosen = landed.firstOrNull { it.eventId != current.event.eventId }
?: landed.firstOrNull() ?: selectedCandidates.firstOrNull() ?: return
selection = current.copy(event = chosen, moved = false)
}
/** Whether the selected block's bottom sits where the action bar would cover it. */
@@ -441,16 +453,26 @@ class TimelineDragController {
/** Point the selection at [block]'s event, so the bar acts on the row now there. */
fun adoptSelection(block: TimedBlock) {
val current = selection ?: return
if (current.event != block.event || current.moved) {
selection = current.copy(event = block.event, moved = false)
}
val current = selection?.takeIf { !it.moved } ?: return
if (current.event != block.event) selection = current.copy(event = block.event)
}
/** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false)
private set
/** Whether the finger has moved since the pickup; only then may the view auto-scroll. */
var pointerMoved: Boolean by mutableStateOf(false)
private set
/** Touch slop, past which the finger counts as having moved. */
var slopPx: Float = 0f
private var pickupPointer = Offset.Zero
/** The end a settling resize asked for, which the grid has to show before it settles. */
private var settledEnd: Instant? = null
/**
* Whether the grid itself now draws the settled drop at its landing slot.
* The copy may only be handed back once this is true, or the source ghost
@@ -527,6 +549,8 @@ class TimelineDragController {
liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot
pointer = pointerInRoot
pickupPointer = pointerInRoot
pointerMoved = false
originSlot = null
recompute()
originSlot = drag?.slot
@@ -534,6 +558,7 @@ class TimelineDragController {
fun move(pointerInRoot: Offset) {
pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
recompute()
}
@@ -546,12 +571,14 @@ class TimelineDragController {
clipOffsetMin = 0
titleLines = 0
isDragging = false
pointerMoved = false
liftedInstanceId = null
originSlot = null
drag = null
settling = null
settledOnGrid = false
settledOrigin = null
settledEnd = null
}
/**
@@ -582,10 +609,11 @@ class TimelineDragController {
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
.firstOrNull() ?: return
val end = settledEnd
settledOnGrid = blocks.any { block ->
block.startMin == slice.startMin &&
// The end too: a bottom-edge resize keeps the start.
block.endMin == slice.startMin + slice.spanMin &&
// A resize may keep its start, so its end has to have landed too.
(end == null || block.event.end == end) &&
(
block.event.eventId == landed.event.eventId ||
(
@@ -645,8 +673,14 @@ class TimelineDragController {
this.titleLines = titleLines
settling = null
isDragging = true
pointerMoved = true
liftedInstanceId = block.event.instanceId
pointer = pointerInRoot
// Measured from the edge's own column: the handle overhangs into the next.
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeIndex = geometry.days.indexOf(start.date.plus(edgeDay, DateTimeUnit.DAY))
resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
originSlot = null
@@ -667,11 +701,13 @@ class TimelineDragController {
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
return TimelineResizeDrop(
val drop = TimelineResizeDrop(
event = landed.event,
start = wallClockInstant(landed.date, landed.eventStartMin, zone),
end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone),
)
settledEnd = drop.end
return drop
}
/**
@@ -767,6 +803,7 @@ class TimelineDragController {
fun release() {
settling = null
settledOnGrid = false
settledEnd = null
handOver()
}
@@ -871,6 +908,7 @@ fun rememberTimelineDragController(): TimelineDragController {
val density = LocalDensity.current
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
controller.slopPx = LocalViewConfiguration.current.touchSlop
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
@@ -953,8 +991,10 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Both live here rather than beside the controller: they read [drag], which
// changes every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.slot)
LaunchedEffect(controller.isDragging) {
if (controller.isDragging) controller.autoScroll()
// Only once the finger has moved: a long press held near an edge must not
// scroll the drop target out from under a finger that only means to select.
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
if (controller.isDragging && controller.pointerMoved) controller.autoScroll()
}
// Hold the landed copy until the write is done — including the time a
// recurring drop's scope dialog is up — and then until the grid draws the
@@ -548,7 +548,7 @@ private fun WeekContent(
}
// A resize keeps its event selected, at its new length (#141).
dragController.onResize = { drop: TimelineResizeDrop ->
val onResize: (TimelineResizeDrop) -> Unit = { drop ->
dragController.select(
drop.event.copy(start = drop.start, end = drop.end),
moved = true,
@@ -576,7 +576,10 @@ private fun WeekContent(
snapshotFlow { pagerState.currentPage }.drop(1).collect { dragController.deselect() }
}
LaunchedEffect(morph != null) { if (morph != null) dragController.deselect() }
val orphaned = selection != null && dragController.selectedBlocks == 0 &&
LaunchedEffect(selection, dragController.settling, dragController.isDragging, dragController.selectedCandidates.size) {
dragController.settleMovedSelection()
}
val orphaned = selection != null && dragController.selectedCandidates.isEmpty() &&
!dragController.isDragging && dragController.settling == null
LaunchedEffect(orphaned) {
if (!orphaned) return@LaunchedEffect
@@ -613,6 +616,7 @@ private fun WeekContent(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
} else WeekPager(
pagerState = pagerState,
@@ -627,6 +631,7 @@ private fun WeekContent(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -653,6 +658,7 @@ private fun WeekPager(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
@@ -701,6 +707,7 @@ private fun WeekPager(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -735,6 +742,7 @@ private fun TimelineMorphFrame(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val fromState by morph.fromState.collectAsStateWithLifecycle()
val toState by morph.toState.collectAsStateWithLifecycle()
@@ -790,6 +798,7 @@ private fun TimelineMorphFrame(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -883,6 +892,7 @@ internal fun WeekSuccess(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
WeekFrame(
weekStart = state.weekStart,
@@ -902,6 +912,7 @@ internal fun WeekSuccess(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -924,6 +935,7 @@ private fun WeekPage(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val geometry = remember(state.weekStart, state.days.size) {
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
@@ -943,6 +955,7 @@ private fun WeekPage(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
@@ -966,6 +979,7 @@ private fun TimelinePageContent(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -996,6 +1010,7 @@ private fun TimelinePageContent(
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -1225,6 +1240,7 @@ private fun Timeline(
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
val dark = isSystemInDarkTheme()
@@ -1322,6 +1338,7 @@ private fun Timeline(
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
scrollState = scrollState,
modifier = Modifier.fillMaxHeight(),
)
@@ -1344,6 +1361,7 @@ private fun DayColumnCard(
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
scrollState: ScrollState,
modifier: Modifier = Modifier,
) {
@@ -1455,6 +1473,7 @@ private fun DayColumnCard(
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
onResize = onResize,
modifier = Modifier
.laneSlot(place)
.offset(y = place.y + topInset)
@@ -1499,6 +1518,7 @@ private fun EventBlock(
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
@@ -1605,9 +1625,9 @@ private fun EventBlock(
if (selected && !lifted) dragController.adoptSelection(block)
}
// Kept composed while hidden: removing them would cancel their own resize.
DisposableEffect(selected) {
if (selected) dragController.noteSelectedBlock(true)
onDispose { if (selected) dragController.noteSelectedBlock(false) }
DisposableEffect(selected, block.event) {
if (selected) dragController.noteSelectedBlock(block.event, true)
onDispose { if (selected) dragController.noteSelectedBlock(block.event, false) }
}
val showHandles = selected && draggable
val handlesShown = !lifted && !dragController.isDragging
@@ -1668,7 +1688,7 @@ private fun EventBlock(
dragController.beginResize(block, edge, zone, handleLines, pointer)
}
}
val end = { dragController.finishResize(zone)?.let(dragController.onResize) }
val end = { dragController.finishResize(zone)?.let(onResize) }
val handleIn by animateFloatAsState(
targetValue = if (handlesShown) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
@@ -58,6 +58,7 @@ internal fun WeekViewPreview(
onOpenDay = {},
onCreateAt = { _, _ -> },
onDrop = {},
onResize = {},
)
}
}