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( EventMoveHost(
reschedule, reschedule,
modifier = Modifier.fillMaxSize(), 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). // 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) var selectionCompact by mutableStateOf(false)
internal set 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. */ /** Whether the selection's action bar is showing — it steps aside mid-gesture. */
val selectionBarShown: Boolean get() = selection != null && !selectionBusy val selectionBarShown: Boolean get() = selection != null && !selectionBusy
@@ -1,12 +1,15 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material.icons.Icons 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.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingToolbarDefaults import androidx.compose.material3.FloatingToolbarDefaults
import androidx.compose.material3.FloatingToolbarHorizontalFabPosition
import androidx.compose.material3.HorizontalFloatingToolbar import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -17,13 +20,16 @@ import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
/** /**
* The action bar a selected event brings up in place of the FAB (#141). Edit and * The action bar a selected event brings up in the FAB's corner (#141): a
* Delete are offered only where the event's calendar allows them; a read-only * toolbar that folds into its own button and back out of it. Edit and Delete are
* event can still be duplicated into a writable calendar, or shared. * 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun EventSelectionBar( fun EventSelectionBar(
expanded: Boolean,
onToggle: () -> Unit,
canEdit: Boolean, canEdit: Boolean,
canDelete: Boolean, canDelete: Boolean,
onEdit: () -> Unit, onEdit: () -> Unit,
@@ -33,7 +39,22 @@ fun EventSelectionBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
HorizontalFloatingToolbar( 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(), colors = FloatingToolbarDefaults.vibrantFloatingToolbarColors(),
modifier = modifier, modifier = modifier,
) { ) {
@@ -11,13 +11,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -25,7 +22,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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. // Held past the selection clearing, so the bar has something to draw on its way out.
val held = remember { mutableStateOf<EventSelection?>(null) } val held = remember { mutableStateOf<EventSelection?>(null) }
host.selection?.let { held.value = it } host.selection?.let { held.value = it }
// Over the selected event's bottom handle the bar shrinks to one button in // Folded into its button while it would cover the selected event's bottom
// the FAB's corner; tapping it brings the whole bar back for this selection. // handle; the button opens and folds it either way, for this selection.
var opened by remember(host.selection?.event, host.selectionCompact) { mutableStateOf(false) } var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
val compact = host.selectionCompact && !opened 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()) { Box(modifier = modifier.fillMaxSize()) {
AnimatedVisibility( AnimatedVisibility(
// Out of the way while the event is dragged or resized, so the // Out of the way while the event is dragged or resized, so the
// bottom of the timeline is there to drop on. // bottom of the timeline is there to drop on.
visible = host.selectionBarShown && !compact, visible = host.selectionBarShown,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(), // From the FAB's corner it stands in for.
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(), enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec(), transformOrigin = cornerOrigin) +
fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec(), transformOrigin = cornerOrigin) +
fadeOut(),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomEnd)
.navigationBarsPadding() .navigationBarsPadding()
.padding(bottom = SnackChipMargin), .padding(end = SnackChipMargin, bottom = SnackChipMargin),
) { ) {
val selection = held.value ?: return@AnimatedVisibility val selection = held.value ?: return@AnimatedVisibility
val event = selection.event val event = selection.event
val calendar = calendars[event.calendarId] val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true val canEdit = calendar?.canModifyContents == true
EventSelectionBar( EventSelectionBar(
expanded = expanded,
onToggle = { chosen = !expanded },
canEdit = canEdit, canEdit = canEdit,
canDelete = canEdit && !calendar.isManaged, canDelete = canEdit && !calendar.isManaged,
onEdit = { 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) { val message = when (failure) {
null -> null null -> null
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed) EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
@@ -158,7 +150,7 @@ fun EventSelectionHost(
.navigationBarsPadding() .navigationBarsPadding()
.padding( .padding(
start = SnackChipMargin, start = SnackChipMargin,
bottom = SnackChipMargin + if (host.selectionBarShown && !compact) SelectionBarClearance else 0.dp, bottom = SnackChipMargin + if (host.selectionBarShown && expanded) SelectionBarClearance else 0.dp,
) )
.height(SnackChipHeight), .height(SnackChipHeight),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
@@ -166,8 +158,8 @@ fun EventSelectionHost(
SnackChip( SnackChip(
visible = failure != null, visible = failure != null,
message = shownMessage.value, message = shownMessage.value,
// Clear of the compact button in the FAB's corner. // Clear of the folded bar's button in the FAB's corner.
maxWidth = chipMaxWidth - if (compact) SelectionBarClearance else 0.dp, 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.ScrollState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -107,7 +106,6 @@ fun HourGutter(
} }
} }
} }
Spacer(Modifier.height(dragController.bottomRoom()))
} }
dragStartMin?.let { startMin -> dragStartMin?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp) 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.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R 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). */ /** How far a selected block is lifted, animated between 0 and 1 (#141). */
@Composable @Composable
fun selectionLift(selected: Boolean): Float { fun selectionLift(selected: Boolean): Float {
@@ -66,7 +66,6 @@ import androidx.compose.ui.zIndex
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import de.jeanlucmakiola.calendula.ui.common.PickUp import de.jeanlucmakiola.calendula.ui.common.PickUp
import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance 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.ResizeEdge
import de.jeanlucmakiola.calendula.ui.common.ResizeHandle import de.jeanlucmakiola.calendula.ui.common.ResizeHandle
import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget
@@ -1279,9 +1278,6 @@ private fun Timeline(
.verticalScroll(scrollState) .verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it }, .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( TimelineColumns(
geometry = geometry, geometry = geometry,
gap = COLUMN_GAP, gap = COLUMN_GAP,
+1
View File
@@ -100,6 +100,7 @@
<string name="event_resize_done">Changed to %1$s</string> <string name="event_resize_done">Changed to %1$s</string>
<string name="event_resize_recurring_title">Change recurring event</string> <string name="event_resize_recurring_title">Change recurring event</string>
<string name="event_selection_actions">Event actions</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_action_load_failed">Couldn\'t open the event</string>
<string name="event_move_undo">Undo</string> <string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string> <string name="event_move_undone">Move undone</string>