From 7d48e13b1c155d00f941e91211aed2a801472a3a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 9 Oct 2026 12:57:48 +0200 Subject: [PATCH] Make the FAB itself the selection bar's toggle (#141) --- .../calendula/ui/CalendarHost.kt | 26 +-- .../calendula/ui/common/CalendarChrome.kt | 10 +- .../calendula/ui/common/CalendarFabColumn.kt | 74 ++++++++- .../calendula/ui/common/EventSelectionBar.kt | 31 +--- .../calendula/ui/common/EventSelectionHost.kt | 151 ++++++++---------- 5 files changed, 161 insertions(+), 131 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 51068118..48a98dce 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.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(), ) 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 ef620f4b..37e2282b 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 @@ -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 }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt index bc686171..1b2dc762 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -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), + ) + } + } + } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt index 8ea4804a..86d58fa2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt @@ -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) 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 0c9d27f4..fcef26dd 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,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(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(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, + ) } }