Fix review findings on selection, resize and chips (#141)

This commit is contained in:
2026-10-09 18:29:08 +02:00
parent e195160f8c
commit 3fe2cd4e9a
10 changed files with 89 additions and 97 deletions
@@ -58,6 +58,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventActionsViewModel
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -600,18 +601,17 @@ fun CalendarHost(
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
// right after the calendar views so later overlays cover it.
val actionFailure by eventActions.failure.collectAsStateWithLifecycle()
EventMoveHost(
reschedule,
modifier = Modifier.fillMaxSize(),
raised = chromeHost.selectionBarShown && chromeHost.selectionBarExpanded,
actionFailure = eventActionFailureMessage(actionFailure),
onActionFailureShown = eventActions::consumeFailure,
)
// The delete prompt and failure chip of a selected event's actions (#141).
EventSelectionHost(
host = chromeHost,
viewModel = eventActions,
modifier = Modifier.fillMaxSize(),
)
EventSelectionHost(host = chromeHost, viewModel = eventActions)
// Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results.
@@ -250,7 +250,7 @@ fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelecti
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
selection = selection.takeIf { host.selection != null },
selection = selection,
)
}
}
@@ -52,6 +52,8 @@ fun EventMoveHost(
viewModel: RescheduleViewModel,
modifier: Modifier = Modifier,
raised: Boolean = false,
actionFailure: String? = null,
onActionFailureShown: () -> Unit = {},
) {
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
@@ -78,8 +80,10 @@ fun EventMoveHost(
val resizedLabel = moved?.endMillis?.let { end ->
"$movedLabel–${formatTime(end, use24Hour, locale)}"
}
// One chip for every message down here: a failed selection action (#141)
// shows when no move has anything to say, rather than stacking over it.
val message = when (outcome) {
null -> null
null -> actionFailure
is MoveOutcome.Moved -> if (resizedLabel != null) {
stringResource(R.string.event_resize_done, resizedLabel)
} else {
@@ -104,6 +108,11 @@ 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)
@@ -130,7 +139,7 @@ fun EventMoveHost(
// write anyway, so offering it would be a button that does nothing.
val undo = content.undo?.takeIf { !writeInFlight }
SnackChip(
visible = outcome != null,
visible = outcome != null || actionFailure != null,
message = content.message,
maxWidth = chipMaxWidth,
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
@@ -162,5 +171,4 @@ private fun formatMovedTo(
/** "10:30" — the end of a resized event, read in the device zone like its start. */
private fun formatTime(millis: Long, use24Hour: Boolean, locale: Locale): String =
localizedDateFormatter(locale, if (use24Hour) "Hm" else "hm")
.format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()))
timeOfDayFormatter(use24Hour, locale).format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()))
@@ -1,21 +1,12 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -23,17 +14,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** How far the bottom chips rise while the selection bar is open, to clear it. */
val SelectionBarClearance = 64.dp + SnackChipMargin
private const val FAILURE_CHIP_MILLIS = 4_000L
/**
* What the FAB becomes while an event is selected (#141): the toggle for its
* action bar, folded while it would cover the selected event's bottom handle.
@@ -95,57 +81,21 @@ fun rememberFabSelection(
)
}
/**
* What else a selected event's actions bring up over the calendar (#141): the
* delete confirmation, and a chip for an action that failed.
*/
/** What a failed action on a selected event says, for the move host's chip to show (#141). */
@Composable
fun EventSelectionHost(
host: ChromeHost,
viewModel: EventActionsViewModel,
modifier: Modifier = Modifier,
) {
fun eventActionFailureMessage(failure: EventActionFailure?): String? = when (failure) {
null -> null
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
EventActionFailure.ShareFailed -> stringResource(R.string.event_share_failed)
EventActionFailure.DeleteFailed -> stringResource(R.string.event_delete_failed)
EventActionFailure.WriteDenied -> stringResource(R.string.event_delete_write_denied)
EventActionFailure.Gone -> stringResource(R.string.event_move_gone)
}
/** The delete confirmation a selected event's Delete brings up (#141). */
@Composable
fun EventSelectionHost(host: ChromeHost, viewModel: EventActionsViewModel) {
val deletePrompt by viewModel.deletePrompt.collectAsStateWithLifecycle()
val failure by viewModel.failure.collectAsStateWithLifecycle()
val message = when (failure) {
null -> null
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
EventActionFailure.ShareFailed -> stringResource(R.string.event_share_failed)
EventActionFailure.DeleteFailed -> stringResource(R.string.event_delete_failed)
EventActionFailure.WriteDenied -> stringResource(R.string.event_delete_write_denied)
EventActionFailure.Gone -> stringResource(R.string.event_move_gone)
}
val shownMessage = remember { mutableStateOf("") }
if (message != null) shownMessage.value = message
LaunchedEffect(failure) {
if (failure == null) return@LaunchedEffect
delay(FAILURE_CHIP_MILLIS)
viewModel.consumeFailure()
}
val barOpen = host.selectionBarShown && host.selectionBarExpanded
BoxWithConstraints(modifier.fillMaxSize()) {
// Clear of the FAB's band, as the move chip is.
val chipMaxWidth = maxWidth - SnackChipMargin * 2 - SelectionBarClearance
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.navigationBarsPadding()
.padding(
start = SnackChipMargin,
bottom = SnackChipMargin + if (barOpen) SelectionBarClearance else 0.dp,
)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
) {
SnackChip(
visible = failure != null,
message = shownMessage.value,
maxWidth = chipMaxWidth,
)
}
}
deletePrompt?.let { prompt ->
DeleteEventDialog(
isRecurring = prompt.isRecurring,
@@ -27,6 +27,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.floret.locale.currentLocale
/** Width of the hour gutter down the start edge of the day and week timelines. */
@@ -136,7 +137,8 @@ fun HourGutter(
contentAlignment = Alignment.Center,
) {
Text(
text = formatGutterTime(startMin, use24Hour, locale),
// A resized end can sit on midnight: 00:00, not a clamped 23:59.
text = formatGutterTime(startMin % MINUTES_PER_DAY, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
maxLines = 1,
@@ -491,10 +491,7 @@ class RescheduleViewModel @Inject constructor(
null
}
/**
* Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: UTC midnight for an all-day event.
*/
/** Where a resized occurrence now ends; null for a move, which keeps its length. */
private fun resizedEndMillis(prepared: PreparedMove): Long? {
if (prepared.request.target !is MoveTarget.Span) return null
val form = prepared.updated
@@ -502,6 +499,10 @@ class RescheduleViewModel @Inject constructor(
.toEpochMilliseconds()
}
/**
* Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: UTC midnight for an all-day event.
*/
private fun movedStartMillis(prepared: PreparedMove): Long {
val form = prepared.updated
return if (form.isAllDay) {
@@ -32,7 +32,7 @@ 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.
* to the slot under it. While not [enabled] it takes no new touch.
*
* No semantics of its own: TalkBack reaches the same change through the event's
* edit action.
@@ -40,6 +40,7 @@ private val HANDLE_RING = 2.dp
@Composable
fun ResizeHandle(
color: Color,
enabled: Boolean,
onStart: (pointerInRoot: Offset) -> Unit,
onMove: (pointerInRoot: Offset) -> Unit,
onEnd: () -> Unit,
@@ -51,6 +52,9 @@ fun ResizeHandle(
val currentMove by rememberUpdatedState(onMove)
val currentEnd by rememberUpdatedState(onEnd)
val currentCancel by rememberUpdatedState(onCancel)
// Read per gesture: a hidden handle takes no new touch, but turning it off
// mid-resize must not cancel the one it is running.
val currentEnabled by rememberUpdatedState(enabled)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
@@ -60,6 +64,7 @@ fun ResizeHandle(
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
if (!currentEnabled) return@awaitEachGesture
down.consume()
var started = false
var ended = false
@@ -78,7 +78,7 @@ private fun formatSelectionWhen(event: EventInstance, use24Hour: Boolean, locale
val start = event.start.toJavaInstant().atZone(zone)
val end = event.end.toJavaInstant().atZone(zone)
val day = localizedDateFormatter(locale, "EEEdMMM")
val time = localizedDateFormatter(locale, if (use24Hour) "Hm" else "hm")
val time = timeOfDayFormatter(use24Hour, locale)
val endsSameDay = end.toLocalDate() == start.toLocalDate() ||
(end.toLocalTime().toSecondOfDay() == 0 && end.toLocalDate() == start.toLocalDate().plusDays(1))
return if (endsSameDay) {
@@ -21,6 +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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -112,16 +113,12 @@ data class TimelineDrag(
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
}
/**
* 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. [moved] holds from a
* move or resize of it until the grid shows where it landed.
*/
/** The event a long press left selected (#141). */
data class TimelineSelection(
val event: EventInstance,
val date: LocalDate,
/** Set from a move or resize of it until the grid shows where it landed. */
val moved: Boolean = false,
/** Selected by letting go of a lifted block: the block takes the lift over as it is. */
/** Selected by letting go of a lifted block, which then keeps that lift as it is. */
val fromLift: Boolean = false,
)
@@ -413,8 +410,9 @@ class TimelineDragController {
var selection: TimelineSelection? by mutableStateOf(null)
private set
fun select(event: EventInstance, date: LocalDate, moved: Boolean = false, fromLift: Boolean = false) {
selection = TimelineSelection(event, date, moved, fromLift)
fun select(event: EventInstance, moved: Boolean = false, fromLift: Boolean = false) {
if (selection?.event?.instanceId != event.instanceId) selectionCovered = false
selection = TimelineSelection(event, moved, fromLift)
}
fun deselect() {
@@ -422,6 +420,14 @@ 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
fun noteSelectedBlock(shown: Boolean) {
selectedBlocks += if (shown) 1 else -1
}
/** Whether the selected block's bottom sits where the action bar would cover it. */
var selectionCovered: Boolean by mutableStateOf(false)
private set
@@ -535,7 +541,7 @@ class TimelineDragController {
) {
// Picking up another event ends the selection; the selected one keeps
// it through the drag, and the screen moves it along with the drop.
if (!isSelected(block)) selection = null
if (!isSelected(block)) deselect()
source = block
this.clipOffsetMin = clipOffsetMin
this.titleLines = titleLines
@@ -647,19 +653,24 @@ class TimelineDragController {
pointerInRoot: Offset,
) {
val start = block.event.start.toLocalDateTime(zone)
val end = block.event.end.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)
// Wall clock at both ends, as the drop resolves them: across a DST
// change the event's length in minutes isn't its span on the clock.
resizeEndMin = maxOf(
resizeStartMin,
(end.date.toEpochDays() - start.date.toEpochDays()).toInt() * MINUTES_PER_DAY +
end.time.toSecondOfDay() / 60,
)
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
@@ -80,6 +80,7 @@ import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.StayPut
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.flow.first
import androidx.compose.runtime.withFrameNanos
@@ -533,7 +534,6 @@ private fun WeekContent(
val start = drop.startInstant(zone)
dragController.select(
drop.event.copy(start = start, end = start + (drop.event.end - drop.event.start)),
start.toLocalDateTime(zone).date,
moved = true,
)
}
@@ -551,7 +551,6 @@ private fun WeekContent(
dragController.onResize = { drop: TimelineResizeDrop ->
dragController.select(
drop.event.copy(start = drop.start, end = drop.end),
drop.start.toLocalDateTime(zone).date,
moved = true,
)
move?.move(
@@ -578,6 +577,14 @@ 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 &&
!dragController.isDragging && dragController.settling == null
LaunchedEffect(orphaned) {
if (!orphaned) return@LaunchedEffect
// A beat for a re-read to bring the event back under a new row.
delay(SELECTION_ORPHAN_GRACE_MILLIS)
dragController.deselect()
}
val openEvent: (EventInstance) -> Unit = { event ->
dragController.deselect()
onEventClick(event)
@@ -1577,14 +1584,14 @@ private fun EventBlock(
PickUp.Drag
} else {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
dragController.select(block.event, date)
dragController.select(block.event)
PickUp.Hold
}
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
onRelease = { dragController.select(block.event, date, fromLift = draggable) },
onRelease = { dragController.select(block.event, fromLift = draggable) },
)
val lifted = draggable && dragController.ghosts(block)
// Let go of without moving, the block takes the floating copy's place as it
@@ -1606,6 +1613,10 @@ private fun EventBlock(
// 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.
DisposableEffect(selected) {
if (selected) dragController.noteSelectedBlock(true)
onDispose { if (selected) dragController.noteSelectedBlock(false) }
}
val showHandles = selected && draggable
val handlesShown = !lifted && !dragController.isDragging
Box(
@@ -1679,6 +1690,7 @@ private fun EventBlock(
if (!cuts.top) {
ResizeHandle(
color = accent,
enabled = handlesShown,
onStart = begin(ResizeEdge.Top),
onMove = dragController::move,
onEnd = { end() },
@@ -1692,6 +1704,7 @@ private fun EventBlock(
if (!cuts.bottom) {
ResizeHandle(
color = accent,
enabled = handlesShown,
onStart = begin(ResizeEdge.Bottom),
onMove = dragController::move,
onEnd = { end() },
@@ -1706,6 +1719,8 @@ private fun EventBlock(
}
}
private const val SELECTION_ORPHAN_GRACE_MILLIS = 500L
/** How far in from the block's side a resize handle's centre sits. */
private val HANDLE_INSET = 16.dp