Make the FAB itself the selection bar's toggle (#141)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user