Fold the selection bar into its own button and drop the extra scroll room (#141)

This commit is contained in:
2026-10-09 12:29:08 +02:00
parent a64fbc9e70
commit d40bf2c3ee
8 changed files with 59 additions and 55 deletions
@@ -588,7 +588,7 @@ fun CalendarHost(
EventMoveHost(
reschedule,
modifier = Modifier.fillMaxSize(),
raised = chromeHost.selectionBarShown && !chromeHost.selectionCompact,
raised = chromeHost.selectionBarShown && chromeHost.selectionBarExpanded,
)
// The bar a long-pressed event brings up in place of the FAB (#141).
@@ -86,6 +86,10 @@ class ChromeHost {
var selectionCompact by mutableStateOf(false)
internal set
/** Whether the selection's bar is unfolded, rather than folded into its button. */
var selectionBarExpanded 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
@@ -1,12 +1,15 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingToolbarDefaults
import androidx.compose.material3.FloatingToolbarHorizontalFabPosition
import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -17,13 +20,16 @@ import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
/**
* The action bar a selected event brings up in place of the FAB (#141). Edit and
* Delete are offered only where the event's calendar allows them; a read-only
* event can still be duplicated into a writable calendar, or shared.
* The action bar a selected event brings up in the FAB's corner (#141): a
* toolbar that folds into its own button and back out of it. Edit and Delete are
* offered only where the event's calendar allows them; a read-only event can
* still be duplicated into a writable calendar, or shared.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun EventSelectionBar(
expanded: Boolean,
onToggle: () -> Unit,
canEdit: Boolean,
canDelete: Boolean,
onEdit: () -> Unit,
@@ -33,7 +39,22 @@ fun EventSelectionBar(
modifier: Modifier = Modifier,
) {
HorizontalFloatingToolbar(
expanded = true,
expanded = expanded,
floatingActionButton = {
FloatingToolbarDefaults.VibrantFloatingActionButton(onClick = onToggle) {
Icon(
imageVector = if (expanded) {
Icons.AutoMirrored.Filled.KeyboardArrowRight
} else {
Icons.Default.MoreHoriz
},
contentDescription = stringResource(
if (expanded) R.string.event_selection_hide else R.string.event_selection_actions,
),
)
}
},
floatingActionButtonPosition = FloatingToolbarHorizontalFabPosition.End,
colors = FloatingToolbarDefaults.vibrantFloatingToolbarColors(),
modifier = modifier,
) {
@@ -11,13 +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
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -25,7 +22,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -66,28 +66,40 @@ fun EventSelectionHost(
// Held past the selection clearing, so the bar has something to draw on its way out.
val held = remember { mutableStateOf<EventSelection?>(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
// Folded into its button while it would cover the selected event's bottom
// handle; the button opens and folds it either way, for this selection.
var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
val expanded = chosen ?: !host.selectionCompact
SideEffect { host.selectionBarExpanded = expanded }
val cornerOrigin = if (LocalLayoutDirection.current == LayoutDirection.Rtl) {
TransformOrigin(0f, 1f)
} else {
TransformOrigin(1f, 1f)
}
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 && !compact,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
visible = host.selectionBarShown,
// From the FAB's corner it stands in for.
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec(), transformOrigin = cornerOrigin) +
fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec(), transformOrigin = cornerOrigin) +
fadeOut(),
modifier = Modifier
.align(Alignment.BottomCenter)
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(bottom = SnackChipMargin),
.padding(end = SnackChipMargin, bottom = SnackChipMargin),
) {
val selection = held.value ?: return@AnimatedVisibility
val event = selection.event
val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true
EventSelectionBar(
expanded = expanded,
onToggle = { chosen = !expanded },
canEdit = canEdit,
canDelete = canEdit && !calendar.isManaged,
onEdit = {
@@ -115,26 +127,6 @@ 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)
@@ -158,7 +150,7 @@ fun EventSelectionHost(
.navigationBarsPadding()
.padding(
start = SnackChipMargin,
bottom = SnackChipMargin + if (host.selectionBarShown && !compact) SelectionBarClearance else 0.dp,
bottom = SnackChipMargin + if (host.selectionBarShown && expanded) SelectionBarClearance else 0.dp,
)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
@@ -166,8 +158,8 @@ fun EventSelectionHost(
SnackChip(
visible = failure != null,
message = shownMessage.value,
// Clear of the compact button in the FAB's corner.
maxWidth = chipMaxWidth - if (compact) SelectionBarClearance else 0.dp,
// Clear of the folded bar's button in the FAB's corner.
maxWidth = chipMaxWidth - if (host.selectionBarShown) SelectionBarClearance else 0.dp,
)
}
}
@@ -4,7 +4,6 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@@ -107,7 +106,6 @@ fun HourGutter(
}
}
}
Spacer(Modifier.height(dragController.bottomRoom()))
}
dragStartMin?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
@@ -39,7 +39,6 @@ import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
@@ -1191,13 +1190,6 @@ private fun DragCopy(
}
}
/**
* Extra scroll room under the hours while an event is selected, so the last of
* the day can scroll clear of the action bar (#141). The columns and the gutter
* share one scroll state, so both have to add exactly this.
*/
fun TimelineDragController.bottomRoom(): Dp = if (selection != null) SelectionBarClearance else 0.dp
/** How far a selected block is lifted, animated between 0 and 1 (#141). */
@Composable
fun selectionLift(selected: Boolean): Float {
@@ -66,7 +66,6 @@ import androidx.compose.ui.zIndex
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 de.jeanlucmakiola.calendula.ui.common.ResizeEdge
import de.jeanlucmakiola.calendula.ui.common.ResizeHandle
import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget
@@ -1279,9 +1278,6 @@ private fun Timeline(
.verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
) {
// Room past midnight to scroll the day's end clear of the action
// bar, matched by the gutter on the same scroll state (#141).
Spacer(Modifier.height(totalHeight + dragController.bottomRoom()))
TimelineColumns(
geometry = geometry,
gap = COLUMN_GAP,
+1
View File
@@ -100,6 +100,7 @@
<string name="event_resize_done">Changed to %1$s</string>
<string name="event_resize_recurring_title">Change recurring event</string>
<string name="event_selection_actions">Event actions</string>
<string name="event_selection_hide">Hide event actions</string>
<string name="event_action_load_failed">Couldn\'t open the event</string>
<string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string>