From a64fbc9e70b401ebdca44c847c0d3a35abb81541 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 9 Oct 2026 12:06:54 +0200 Subject: [PATCH] Compact the selection bar instead of scrolling the event up (#141) --- .../calendula/ui/CalendarHost.kt | 2 +- .../calendula/ui/common/CalendarChrome.kt | 13 +++++-- .../calendula/ui/common/EventSelectionHost.kt | 36 +++++++++++++++++-- .../calendula/ui/common/TimelineDrag.kt | 24 ++++++------- .../calendula/ui/week/WeekScreen.kt | 25 ++++++------- app/src/main/res/values/strings.xml | 1 + 6 files changed, 71 insertions(+), 30 deletions(-) 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 6e08202..5d4aa0d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -588,7 +588,7 @@ fun CalendarHost( EventMoveHost( reschedule, modifier = Modifier.fillMaxSize(), - raised = chromeHost.selectionBarShown, + raised = chromeHost.selectionBarShown && !chromeHost.selectionCompact, ) // The bar a long-pressed event brings up in place of the FAB (#141). 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 74fc45a..95e9ab3 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 @@ -82,6 +82,10 @@ class ChromeHost { var selectionBusy by mutableStateOf(false) internal set + /** Whether the bar would cover the selected event, and so draws compact. */ + 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 @@ -108,7 +112,7 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) { /** Hands the screen's selected [event] to the host, for as long as it stays selected. */ @Composable -fun PublishSelection(event: EventInstance?, busy: Boolean, onDismiss: () -> Unit) { +fun PublishSelection(event: EventInstance?, busy: Boolean, compact: Boolean, onDismiss: () -> Unit) { val slot = LocalChromeSlot.current ?: return val currentDismiss by rememberUpdatedState(onDismiss) val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } } @@ -118,7 +122,12 @@ fun PublishSelection(event: EventInstance?, busy: Boolean, onDismiss: () -> Unit if (current) host.selection = selection onDispose { if (host.selection === selection) host.selection = null } } - if (current) SideEffect { host.selectionBusy = busy && selection != null } + if (current) { + SideEffect { + host.selectionBusy = busy && selection != null + host.selectionCompact = compact && selection != null + } + } } /** The top bar every calendar screen shares. */ 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 c879eda..60b82fe 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 @@ -11,6 +11,10 @@ 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.material.icons.Icons +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -18,6 +22,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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -61,12 +66,16 @@ fun EventSelectionHost( // Held past the selection clearing, so the bar has something to draw on its way out. val held = remember { mutableStateOf(null) } host.selection?.let { held.value = it } + // Over the selected event's bottom handle the bar shrinks to one button in + // the FAB's corner; tapping it brings the whole bar back for this selection. + var opened by remember(host.selection?.event, host.selectionCompact) { mutableStateOf(false) } + val compact = host.selectionCompact && !opened Box(modifier = modifier.fillMaxSize()) { AnimatedVisibility( // Out of the way while the event is dragged or resized, so the // bottom of the timeline is there to drop on. - visible = host.selectionBarShown, + visible = host.selectionBarShown && !compact, enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(), exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(), modifier = Modifier @@ -106,6 +115,26 @@ fun EventSelectionHost( ) } + AnimatedVisibility( + visible = host.selectionBarShown && compact, + enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(), + exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .navigationBarsPadding() + .padding(end = SnackChipMargin, bottom = SnackChipMargin), + ) { + FloatingActionButton( + onClick = { opened = true }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) { + Icon( + imageVector = Icons.Default.MoreHoriz, + contentDescription = stringResource(R.string.event_selection_actions), + ) + } + } + val message = when (failure) { null -> null EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed) @@ -129,7 +158,7 @@ fun EventSelectionHost( .navigationBarsPadding() .padding( start = SnackChipMargin, - bottom = SnackChipMargin + if (host.selectionBarShown) SelectionBarClearance else 0.dp, + bottom = SnackChipMargin + if (host.selectionBarShown && !compact) SelectionBarClearance else 0.dp, ) .height(SnackChipHeight), contentAlignment = Alignment.CenterStart, @@ -137,7 +166,8 @@ fun EventSelectionHost( SnackChip( visible = failure != null, message = shownMessage.value, - maxWidth = chipMaxWidth, + // Clear of the compact button in the FAB's corner. + maxWidth = chipMaxWidth - if (compact) SelectionBarClearance else 0.dp, ) } } 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 b8772d7..c280ed2 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 @@ -5,7 +5,6 @@ import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.ScrollState import androidx.compose.foundation.MutatePriority -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box @@ -415,8 +414,13 @@ class TimelineDragController { fun deselect() { selection = null + selectionCovered = false } + /** Whether the selected block's bottom sits where the action bar would cover it. */ + var selectionCovered: Boolean by mutableStateOf(false) + private set + /** * 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 @@ -748,21 +752,17 @@ class TimelineDragController { } /** - * Scroll the timeline so a just-selected block, [extraPx] of handle included, - * clears the action bar [clearancePx] tall over the bottom of the viewport — - * without pushing its top out of sight. + * Note where the selected block's bottom edge, [extraPx] of handle included, + * sits against the action bar [clearancePx] tall over the bottom of the + * viewport. Under it, the bar draws itself compact out of the way (#141). */ - suspend fun revealAboveBar(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) { + fun noteSelectedBottom(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) { val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return - val scroll = geometry.scroll ?: return if (!block.isAttached) return val port = viewport.boundsInRoot() - val top = block.positionInRoot().y - val bottom = top + block.size.height + extraPx - val needed = bottom - (port.bottom - clearancePx) - val spare = top - port.top - val by = minOf(needed, spare) - if (by > 0f) scroll.animateScrollBy(by) + val bottom = block.positionInRoot().y + block.size.height + extraPx + val covered = bottom > port.bottom - clearancePx + if (covered != selectionCovered) selectionCovered = covered } /** 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 b100cb7..8b1a4a2 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 @@ -67,7 +67,6 @@ import androidx.compose.ui.geometry.Offset import de.jeanlucmakiola.calendula.ui.common.PickUp import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance import de.jeanlucmakiola.calendula.ui.common.bottomRoom -import androidx.compose.ui.layout.LayoutCoordinates import de.jeanlucmakiola.calendula.ui.common.ResizeEdge import de.jeanlucmakiola.calendula.ui.common.ResizeHandle import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget @@ -571,6 +570,7 @@ private fun WeekContent( PublishSelection( event = selection?.event, busy = dragController.isDragging, + compact = dragController.selectionCovered, onDismiss = dragController::deselect, ) BackHandler(enabled = selection != null) { dragController.deselect() } @@ -1592,18 +1592,11 @@ private fun EventBlock( val lifted = draggable && dragController.ghosts(block) val ghost = ghostAlpha(lifted) val raise = selectionLift(selected) - // Scrolled clear of the action bar as it is selected, handle and all. - val blockCoordinates = remember { arrayOfNulls(1) } + // Where the selected event's bottom handle sits against the action bar, which + // compacts itself rather than cover it. Read off the piece that ends the event. val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() } val barClearancePx = with(density) { SelectionBarClearance.toPx() } - LaunchedEffect(selected) { - if (!selected) return@LaunchedEffect - // A frame for the room under the hours to be laid out first. - withFrameNanos { } - blockCoordinates[0]?.let { - dragController.revealAboveBar(it, revealExtraPx, barClearancePx) - } - } + val tracksBar = selected && !cuts.bottom // 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) { @@ -1620,7 +1613,15 @@ private fun EventBlock( // 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) - .onGloballyPositioned { blockCoordinates[0] = it }, + .then( + if (tracksBar) { + Modifier.onGloballyPositioned { + dragController.noteSelectedBottom(it, revealExtraPx, barClearancePx) + } + } else { + Modifier + }, + ), ) { Box( modifier = Modifier diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4cc941b..c1be5a6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,6 +99,7 @@ Moved to %1$s Changed to %1$s Change recurring event + Event actions Couldn\'t open the event Undo Move undone