Make the FAB itself the selection bar's toggle (#141)

This commit is contained in:
2026-10-09 12:57:48 +02:00
parent f5ec696bc3
commit 7d48e13b1c
5 changed files with 161 additions and 131 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.rememberFabSelection
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
@@ -530,7 +531,21 @@ fun CalendarHost(
showTodayButton = todayInToolbar,
)
},
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
floatingActionButton = {
// While an event is selected the FAB is its action bar's toggle (#141).
val fabSelection = rememberFabSelection(
host = chromeHost,
viewModel = eventActions,
onEdit = onEditEvent,
onDuplicate = { form ->
// As a duplicate from the detail screen: a fresh event,
// through the prefilled-create overlay (#52).
importFormSource = ImportSource.Insert
importForm = form
},
)
CalendarFab(chromeHost, todayInToolbar, fabSelection)
},
) { innerPadding ->
SharedTransitionLayout(
Modifier
@@ -591,17 +606,10 @@ fun CalendarHost(
raised = chromeHost.selectionBarShown && chromeHost.selectionBarExpanded,
)
// The bar a long-pressed event brings up in place of the FAB (#141).
// The delete prompt and failure chip of a selected event's actions (#141).
EventSelectionHost(
host = chromeHost,
viewModel = eventActions,
onEdit = onEditEvent,
onDuplicate = { form ->
// As a duplicate from the detail screen: a fresh event, through
// the prefilled-create overlay (#52).
importFormSource = ImportSource.Insert
importForm = form
},
modifier = Modifier.fillMaxSize(),
)
@@ -190,12 +190,15 @@ fun CalendarTopBar(
)
}
/** The FAB column every calendar screen shares; it steps aside for a selection's bar. */
/**
* The FAB column every calendar screen shares. While an event is selected the
* FAB is [selection]'s toggle instead, and it steps aside mid-gesture.
*/
@Composable
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
val chrome = host.chrome ?: return
AnimatedVisibility(
visible = host.selection == null,
visible = !host.selectionBusy,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
) {
@@ -204,6 +207,7 @@ fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
selection = selection.takeIf { host.selection != null },
)
}
}
@@ -1,12 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
@@ -15,15 +23,28 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
class FabSelection(
val expanded: Boolean,
val onToggle: () -> Unit,
val bar: @Composable () -> Unit,
)
private enum class FabIcon { Create, Open, Fold }
/**
* The FAB stack shared by the three calendar views: a persistent "+" to
* create an event, with the jump-to-today pill appearing above it whenever
* the view isn't anchored on today.
* the view isn't anchored on today. While an event is selected the same FAB,
* in the same spot, opens and folds the selection's action bar, which unrolls
* from it towards the start.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -32,13 +53,14 @@ fun CalendarFabColumn(
todayText: String,
onToday: () -> Unit,
onCreate: () -> Unit,
selection: FabSelection? = null,
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
AnimatedVisibility(
visible = todayVisible,
visible = todayVisible && selection == null,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
) {
@@ -48,11 +70,49 @@ fun CalendarFabColumn(
text = { Text(todayText) },
)
}
FloatingActionButton(onClick = onCreate) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_new_title),
)
// Held past the selection clearing, so the bar has something to fold away.
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
selection?.let { heldBar.value = it.bar }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(
visible = selection?.expanded == true,
enter = expandHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
expandFrom = Alignment.End,
) + fadeIn(),
exit = shrinkHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
shrinkTowards = Alignment.End,
) + fadeOut(),
) {
heldBar.value?.invoke()
}
val icon = when {
selection == null -> FabIcon.Create
selection.expanded -> FabIcon.Fold
else -> FabIcon.Open
}
FloatingActionButton(onClick = selection?.onToggle ?: onCreate) {
Crossfade(targetState = icon, label = "fab-icon") { shown ->
when (shown) {
FabIcon.Create -> Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_new_title),
)
FabIcon.Open -> Icon(
imageVector = Icons.Default.MoreHoriz,
contentDescription = stringResource(R.string.event_selection_actions),
)
FabIcon.Fold -> Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = stringResource(R.string.event_selection_hide),
)
}
}
}
}
}
}
@@ -1,16 +1,12 @@
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.FloatingActionButton
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
@@ -21,16 +17,14 @@ import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
/**
* 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
* The action bar a selected event unrolls from the FAB (#141), in the FAB's own
* colours so it stands clear of the calendar behind 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,
@@ -40,25 +34,8 @@ fun EventSelectionBar(
modifier: Modifier = Modifier,
) {
HorizontalFloatingToolbar(
expanded = expanded,
floatingActionButton = {
// A regular FAB, the size of the one it stands in for — not the
// toolbar's own larger one.
FloatingActionButton(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.standardFloatingToolbarColors(),
expanded = true,
colors = FloatingToolbarDefaults.vibrantFloatingToolbarColors(),
modifier = modifier,
) {
if (canEdit) BarAction(Icons.Default.Edit, R.string.event_detail_edit, onEdit)
@@ -1,17 +1,11 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
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.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
@@ -22,10 +16,7 @@ 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
@@ -38,68 +29,43 @@ 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 up, to clear it. */
/** 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 a selected event brings up over the calendar (#141): the action bar at the
* bottom, the delete confirmation, and a chip for an action that failed.
* 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.
* Null with nothing selected.
*/
@Composable
fun EventSelectionHost(
fun rememberFabSelection(
host: ChromeHost,
viewModel: EventActionsViewModel,
onEdit: (EventInstance) -> Unit,
onDuplicate: (EventForm) -> Unit,
modifier: Modifier = Modifier,
) {
): FabSelection? {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val deletePrompt by viewModel.deletePrompt.collectAsStateWithLifecycle()
val failure by viewModel.failure.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
val chooserTitle = stringResource(R.string.event_share_chooser_title)
// 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 }
// 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.
// What the FAB has been told for this selection, over what the coverage says.
var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
val expanded = chosen ?: !host.selectionCompact
SideEffect { host.selectionBarExpanded = expanded }
SideEffect { host.selectionBarExpanded = expanded && host.selection != null }
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,
// 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.BottomEnd)
.navigationBarsPadding()
.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
val selection = host.selection ?: return null
val event = selection.event
val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true
return FabSelection(
expanded = expanded,
onToggle = { chosen = !expanded },
bar = {
EventSelectionBar(
expanded = expanded,
onToggle = { chosen = !expanded },
canEdit = canEdit,
canDelete = canEdit && !calendar.isManaged,
onEdit = {
@@ -125,43 +91,58 @@ fun EventSelectionHost(
},
onDelete = { requireWrite { viewModel.requestDelete(event) } },
)
}
},
)
}
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()
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val chipMaxWidth = maxWidth - SnackChipMargin * 2
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.navigationBarsPadding()
.padding(
start = SnackChipMargin,
bottom = SnackChipMargin + if (host.selectionBarShown && expanded) SelectionBarClearance else 0.dp,
)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
) {
SnackChip(
visible = failure != null,
message = shownMessage.value,
// Clear of the folded bar's button in the FAB's corner.
maxWidth = chipMaxWidth - if (host.selectionBarShown) SelectionBarClearance else 0.dp,
/**
* What else a selected event's actions bring up over the calendar (#141): the
* delete confirmation, and a chip for an action that failed.
*/
@Composable
fun EventSelectionHost(
host: ChromeHost,
viewModel: EventActionsViewModel,
modifier: Modifier = Modifier,
) {
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,
)
}
}