diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 48a98dce..e94078b8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt index 5cd603af..a3f41db0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt @@ -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, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt index 1f0b9770..c3c3ae0b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -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())) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt index fcef26dd..78e37fe2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt index 7dc762d9..5b94deac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index 13f20379..d30a5e8b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt index 5e5c6304..42cdc40b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt index ee142fba..7e460912 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index cb5a249d..57796df0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -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 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 8a3026f1..61895a3d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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