From 44f91a963a0b14eb67af480bd7bc985fea9ecfee Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 9 Oct 2026 11:00:44 +0200 Subject: [PATCH] Select timeline events with a long press and act on them from a bar (#141) --- .../calendula/ui/CalendarHost.kt | 23 ++- .../calendula/ui/common/CalendarChrome.kt | 53 +++++- .../ui/common/EventActionsViewModel.kt | 135 +++++++++++++++ .../calendula/ui/common/EventDrag.kt | 115 ++++++++++--- .../calendula/ui/common/EventMoveHost.kt | 12 +- .../calendula/ui/common/EventMoveScope.kt | 7 + .../calendula/ui/common/EventSelectionBar.kt | 52 ++++++ .../calendula/ui/common/EventSelectionHost.kt | 154 ++++++++++++++++++ .../calendula/ui/common/TimelineDrag.kt | 58 +++++++ .../calendula/ui/week/WeekScreen.kt | 72 ++++++-- app/src/main/res/values/strings.xml | 1 + 11 files changed, 636 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt 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 a18abae0..5217f9c9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -55,7 +55,9 @@ import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.identity.rememberReduceMotion 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.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars @@ -386,6 +388,7 @@ fun CalendarHost( // survives view switches; the calendar surfaces read it out of LocalEventMove. val reschedule: RescheduleViewModel = hiltViewModel() val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle() + val eventActions: EventActionsViewModel = hiltViewModel() val onEditEvent: (EventInstance) -> Unit = { event -> val key = longArrayOf( event.eventId, @@ -582,7 +585,25 @@ fun CalendarHost( // Scope prompt + confirmation/undo snackbar for a dropped event, declared // right after the calendar views so later overlays cover it. - EventMoveHost(reschedule, modifier = Modifier.fillMaxSize()) + EventMoveHost( + reschedule, + modifier = Modifier.fillMaxSize(), + raised = chromeHost.selection != null, + ) + + // The bar a long-pressed event brings up in place of the FAB (#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(), + ) // Search overlay — below detail/edit in the Box so a tapped result's // detail screen draws on top, and closing it returns to the results. 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 173d29d2..77b81842 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 @@ -1,5 +1,10 @@ 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.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -16,6 +21,9 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.Immutable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable @@ -24,6 +32,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.datetime.LocalDate /** @@ -53,12 +62,22 @@ class ScreenActions( val onCreate: () -> Unit, ) +/** + * An event a long press left selected (#141), which the host answers with the + * action bar in place of the FAB. [dismiss] clears it on the screen that owns it. + */ +@Stable +class EventSelection(val event: EventInstance, val dismiss: () -> Unit) + /** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */ @Stable class ChromeHost { var chrome by mutableStateOf(null) private set + var selection by mutableStateOf(null) + internal set + var actions: ScreenActions? = null private set @@ -80,6 +99,20 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) { if (slot.current) SideEffect { slot.host.publish(chrome, actions) } } +/** Hands the screen's selected [event] to the host, for as long as it stays selected. */ +@Composable +fun PublishSelection(event: EventInstance?, onDismiss: () -> Unit) { + val slot = LocalChromeSlot.current ?: return + val currentDismiss by rememberUpdatedState(onDismiss) + val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } } + val host = slot.host + val current = slot.current + DisposableEffect(host, selection, current) { + if (current) host.selection = selection + onDispose { if (host.selection === selection) host.selection = null } + } +} + /** The top bar every calendar screen shares. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -136,16 +169,22 @@ fun CalendarTopBar( ) } -/** The FAB column every calendar screen shares. */ +/** The FAB column every calendar screen shares; it steps aside for a selection's bar. */ @Composable fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) { val chrome = host.chrome ?: return - CalendarFabColumn( - todayVisible = chrome.awayFromToday && !todayInToolbar, - todayText = chrome.todayText, - onToday = { host.actions?.onToday?.invoke() }, - onCreate = { host.actions?.onCreate?.invoke() }, - ) + AnimatedVisibility( + visible = host.selection == null, + enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(), + exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(), + ) { + CalendarFabColumn( + todayVisible = chrome.awayFromToday && !todayInToolbar, + todayText = chrome.todayText, + onToday = { host.actions?.onToday?.invoke() }, + onCreate = { host.actions?.onCreate?.invoke() }, + ) + } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt new file mode 100644 index 00000000..933c8fb4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt @@ -0,0 +1,135 @@ +package de.jeanlucmakiola.calendula.ui.common + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException +import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.data.ics.IcsExporter +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.toDuplicateForm +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.datetime.TimeZone +import kotlin.coroutines.cancellation.CancellationException +import javax.inject.Inject + +/** A delete waiting on its confirmation dialog. */ +data class DeletePrompt(val event: EventInstance, val isRecurring: Boolean) + +/** How an action on a selected event ended, when it has something to say. */ +enum class EventActionFailure { LoadFailed, ShareFailed, DeleteFailed, WriteDenied, Gone } + +/** + * The event actions a selected block offers outside the detail screen (#141): + * duplicate, share and delete, each loading the event on demand. Hosted at + * `CalendarHost` beside [RescheduleViewModel]. + */ +@HiltViewModel +class EventActionsViewModel @Inject constructor( + private val repository: CalendarRepository, + private val icsExporter: IcsExporter, + @IoDispatcher private val io: CoroutineDispatcher, +) : ViewModel() { + + /** Every calendar by id, for what a selected event's bar may offer. */ + val calendars: StateFlow> = repository.calendars() + .map { list -> list.associateBy { it.id } } + .catch { emit(emptyMap()) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = emptyMap(), + ) + + private val _deletePrompt = MutableStateFlow(null) + val deletePrompt: StateFlow = _deletePrompt.asStateFlow() + + private val _failure = MutableStateFlow(null) + val failure: StateFlow = _failure.asStateFlow() + + /** A create form copying [event], or null when it can't be read. */ + suspend fun duplicateForm(event: EventInstance): EventForm? { + val detail = load(event) ?: return null + val canModify = calendars.value[event.calendarId]?.canModifyContents == true + return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault()) + } + + /** A staged `.ics` for [event], or null — reported — when it can't be built. */ + suspend fun shareUri(event: EventInstance): Uri? { + val uri = load(event)?.let { withContext(io) { icsExporter.stageEventShare(it) } } + if (uri == null && _failure.value == null) _failure.value = EventActionFailure.ShareFailed + return uri + } + + /** Ask to delete [event]; the dialog it needs depends on whether it recurs. */ + fun requestDelete(event: EventInstance) { + viewModelScope.launch { + val detail = load(event) ?: return@launch + _deletePrompt.value = DeletePrompt(event, isRecurring = !detail.rrule.isNullOrBlank()) + } + } + + fun confirmDelete(scope: RecurringWriteScope) { + val prompt = _deletePrompt.value ?: return + _deletePrompt.value = null + viewModelScope.launch { + val event = prompt.event + _failure.value = try { + repository.deleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope) + null + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + EventActionFailure.WriteDenied + } catch (e: NoSuchEventException) { + EventActionFailure.Gone + } catch (e: Exception) { + EventActionFailure.DeleteFailed + } + } + } + + fun cancelDelete() { + _deletePrompt.value = null + } + + /** The chooser itself couldn't open — nothing on the device takes an `.ics`. */ + fun reportShareFailed() { + _failure.value = EventActionFailure.ShareFailed + } + + fun consumeFailure() { + _failure.value = null + } + + private suspend fun load(event: EventInstance): EventDetail? = try { + val detail = repository.eventDetail(event.eventId) + // The row holds the series start; the actions work on this occurrence. + detail.copy(instance = detail.instance.copy(start = event.start, end = event.end)) + } catch (e: CancellationException) { + throw e + } catch (e: NoSuchEventException) { + _failure.value = EventActionFailure.Gone + null + } catch (e: Exception) { + _failure.value = EventActionFailure.LoadFailed + null + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt index 12bf70f7..8c31288f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt @@ -24,31 +24,49 @@ import androidx.compose.ui.unit.dp /** How far the finger may wander during the hold — well under touch slop. */ private val PICKUP_TOLERANCE = 6.dp +/** What a long press on a block turned into. */ +enum class PickUp { + /** Nothing to pick up; the gesture is left alone. */ + None, + + /** Lifted and following the finger. */ + Drag, + + /** Held but not movable — the gesture is swallowed until the finger lifts. */ + Hold, +} + /** * Pick an event block up with a long press and drag it, without * `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an * ancestor consumes or the finger leaves the block, which a * `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout * is deliberately not consumed, so a scroll starting on top of a block survives. + * + * [onRelease] is the long press that never moved: the finger lifted within touch + * slop of where it was picked up, or a [PickUp.Hold] ended. Without it, such a + * release is a drop like any other. [immediate] also lifts the block as soon as + * the finger moves past touch slop, with no hold — for a block already selected. */ @Composable fun rememberEventDragSource( enabled: Boolean, key: Any?, - onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit, + onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> PickUp, onMove: (pointerInRoot: Offset) -> Unit, onDrop: () -> Unit, onCancel: () -> Unit, -): Modifier = rememberDragSurface( + onRelease: (() -> Unit)? = null, + immediate: Boolean = false, +): Modifier = rememberPickUpSurface( enabled = enabled, key = key, - onPickUp = { _, pointerInRoot, nodeInRoot, size -> - onPickUp(pointerInRoot, nodeInRoot, size) - true - }, + immediate = immediate, + onPickUp = { _, pointerInRoot, nodeInRoot, size -> onPickUp(pointerInRoot, nodeInRoot, size) }, onMove = onMove, onDrop = onDrop, onCancel = onCancel, + onRelease = onRelease, ) /** @@ -65,6 +83,29 @@ fun rememberDragSurface( onMove: (pointerInRoot: Offset) -> Unit, onDrop: () -> Unit, onCancel: () -> Unit, +): Modifier = rememberPickUpSurface( + enabled = enabled, + key = key, + immediate = false, + onPickUp = { local, pointer, node, size -> + if (onPickUp(local, pointer, node, size)) PickUp.Drag else PickUp.None + }, + onMove = onMove, + onDrop = onDrop, + onCancel = onCancel, + onRelease = null, +) + +@Composable +private fun rememberPickUpSurface( + enabled: Boolean, + key: Any?, + immediate: Boolean, + onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> PickUp, + onMove: (pointerInRoot: Offset) -> Unit, + onDrop: () -> Unit, + onCancel: () -> Unit, + onRelease: (() -> Unit)?, ): Modifier { if (!enabled) return Modifier val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis @@ -74,27 +115,41 @@ fun rememberDragSurface( val currentMove by rememberUpdatedState(onMove) val currentDrop by rememberUpdatedState(onDrop) val currentCancel by rememberUpdatedState(onCancel) + val currentRelease by rememberUpdatedState(onRelease) return Modifier .onGloballyPositioned { coordinates[0] = it } - .pointerInput(key) { + .pointerInput(key, immediate) { + val slop = viewConfiguration.touchSlop awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) val origin = down.position - val heldStill = withTimeoutOrNull(holdMillis) { - while (true) { + // Null on the timeout — held still — true once an [immediate] + // block has been moved past slop, false when the press is + // abandoned. + val outcome = withTimeoutOrNull(holdMillis) { + var result: Boolean? = null + while (result == null) { val main = awaitPointerEvent() - val change = main.changes.firstOrNull { it.id == down.id } ?: break - if (!change.pressed) break - if (main.changes.count { it.pressed } > 1) break - if ((change.position - origin).getDistance() > tolerance) break - // An ancestor's claim only becomes visible once the whole - // main pass has run, so look again on the final pass. - val final = awaitPointerEvent(PointerEventPass.Final) - if (final.changes.any { it.isConsumed }) break + val change = main.changes.firstOrNull { it.id == down.id } + val distance = change?.let { (it.position - origin).getDistance() } ?: 0f + result = when { + change == null || !change.pressed -> false + main.changes.count { it.pressed } > 1 -> false + immediate && distance > slop && !change.isConsumed -> { + change.consume() + true + } + !immediate && distance > tolerance -> false + // An ancestor's claim only becomes visible once the + // whole main pass has run, so look again on the final pass. + awaitPointerEvent(PointerEventPass.Final).changes.any { it.isConsumed } -> false + else -> null + } } - } == null - if (!heldStill) return@awaitEachGesture + result + } + if (outcome == false) return@awaitEachGesture val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture val took = currentPickUp( @@ -103,8 +158,11 @@ fun rememberDragSurface( layout.positionInRoot(), layout.size, ) - if (!took) return@awaitEachGesture + if (took == PickUp.None) return@awaitEachGesture + // Moved already, for an immediate pickup: it can't be a release. + var moved = outcome == true var dropped = false + var released = false try { // Driven on the initial pass, which runs parent → child: an // ancestor that outranks us (the pinch) has already consumed @@ -116,18 +174,31 @@ fun rememberDragSurface( if (change.isConsumed) break if (!change.pressed) { change.consume() - dropped = true + if (took == PickUp.Hold || (!moved && currentRelease != null)) { + released = true + } else { + dropped = true + } break } if (event.changes.count { it.pressed } > 1) break change.consume() + if (took == PickUp.Hold) continue + if ((change.position - origin).getDistance() > slop) moved = true coordinates[0]?.takeIf { it.isAttached } ?.let { currentMove(it.localToRoot(change.position)) } } } finally { // Also reached when the pointer node is disposed mid-drag — // a cancel, not a drop, and must never write. - if (dropped) currentDrop() else currentCancel() + when { + released -> { + if (took == PickUp.Drag) currentCancel() + currentRelease?.invoke() + } + dropped -> currentDrop() + took == PickUp.Drag -> currentCancel() + } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt index b44ef8d7..1f0b9770 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -48,7 +48,11 @@ private val FAB_BAND = 88.dp * a pill on the FAB's band, leaving the calendar visible behind it. */ @Composable -fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) { +fun EventMoveHost( + viewModel: RescheduleViewModel, + modifier: Modifier = Modifier, + raised: Boolean = false, +) { val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle() val outcome by viewModel.outcome.collectAsStateWithLifecycle() val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle() @@ -114,7 +118,11 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) modifier = Modifier .align(Alignment.BottomStart) .navigationBarsPadding() - .padding(start = SnackChipMargin, bottom = SnackChipMargin) + // Above the selection bar while it is up (#141). + .padding( + start = SnackChipMargin, + bottom = SnackChipMargin + if (raised) SelectionBarClearance else 0.dp, + ) .height(SnackChipHeight), contentAlignment = Alignment.CenterStart, ) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt index c49456b8..92b8eae1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt @@ -131,6 +131,13 @@ fun eventDragAllowed(event: EventInstance): Boolean { return move.dragEnabled && move.allows(event) } +/** + * Whether a long press may select events at all (#141) — the gesture setting, + * whatever the event's calendar allows. A read-only event is selected but stays put. + */ +@Composable +fun eventSelectAllowed(): Boolean = LocalEventMove.current?.dragEnabled == true + /** * A TalkBack action that opens [event] in the edit form, so rescheduling isn't * pointer-only. Null when this event can't be moved. 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 new file mode 100644 index 00000000..14e66c46 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionBar.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.material.icons.Icons +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.Share +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingToolbarDefaults +import androidx.compose.material3.HorizontalFloatingToolbar +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +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. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun EventSelectionBar( + canEdit: Boolean, + canDelete: Boolean, + onEdit: () -> Unit, + onDuplicate: () -> Unit, + onShare: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + HorizontalFloatingToolbar( + expanded = true, + colors = FloatingToolbarDefaults.vibrantFloatingToolbarColors(), + modifier = modifier, + ) { + if (canEdit) BarAction(Icons.Default.Edit, R.string.event_detail_edit, onEdit) + BarAction(Icons.Default.ContentCopy, R.string.event_detail_duplicate, onDuplicate) + BarAction(Icons.Default.Share, R.string.event_detail_share, onShare) + if (canDelete) BarAction(Icons.Default.Delete, R.string.event_detail_delete, onDelete) + } +} + +@Composable +private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon(imageVector = icon, contentDescription = stringResource(label)) + } +} 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 new file mode 100644 index 00000000..0f88cf5a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt @@ -0,0 +1,154 @@ +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.floret.components.SnackChip +import de.jeanlucmakiola.floret.components.SnackChipHeight +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. */ +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. + */ +@Composable +fun EventSelectionHost( + host: ChromeHost, + viewModel: EventActionsViewModel, + onEdit: (EventInstance) -> Unit, + onDuplicate: (EventForm) -> Unit, + modifier: Modifier = Modifier, +) { + 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 } + + Box(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + visible = host.selection != null, + enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(), + exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = SnackChipMargin), + ) { + val selection = held.value ?: return@AnimatedVisibility + val event = selection.event + val calendar = calendars[event.calendarId] + val canEdit = calendar?.canModifyContents == true + EventSelectionBar( + canEdit = canEdit, + canDelete = canEdit && !calendar.isManaged, + onEdit = { + requireWrite { + selection.dismiss() + onEdit(event) + } + }, + onDuplicate = { + requireWrite { + scope.launch { + val form = viewModel.duplicateForm(event) ?: return@launch + selection.dismiss() + onDuplicate(form) + } + } + }, + onShare = { + scope.launch { + val uri = viewModel.shareUri(event) ?: return@launch + if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed() + } + }, + 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.selection != null) SelectionBarClearance else 0.dp, + ) + .height(SnackChipHeight), + contentAlignment = Alignment.CenterStart, + ) { + SnackChip( + visible = failure != null, + message = shownMessage.value, + maxWidth = chipMaxWidth, + ) + } + } + } + + deletePrompt?.let { prompt -> + DeleteEventDialog( + isRecurring = prompt.isRecurring, + onConfirm = { writeScope -> + host.selection?.takeIf { it.event == prompt.event }?.dismiss() + viewModel.confirmDelete(writeScope) + }, + onDismiss = viewModel::cancelDelete, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 0f2fae46..0b80c38d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.ScrollState import androidx.compose.foundation.MutatePriority @@ -27,6 +28,7 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.LayoutCoordinates @@ -102,6 +104,12 @@ data class TimelineDrag( val slot: Pair get() = date to startMin } +/** + * The event a long press left selected (#141): lifted on the grid, with the + * action bar up. [date] is the column the press landed in. + */ +data class TimelineSelection(val event: EventInstance, val date: LocalDate) + /** * One day column's share of a dragged event, ready to draw. Both halves of an * event crossing midnight are previewed, whichever half the finger picked up: @@ -320,6 +328,29 @@ class TimelineDragController { */ private var settledOrigin: Pair? by mutableStateOf(null) + /** The selected event, if any — see [TimelineSelection]. */ + var selection: TimelineSelection? by mutableStateOf(null) + private set + + fun select(event: EventInstance, date: LocalDate) { + selection = TimelineSelection(event, date) + } + + fun deselect() { + selection = null + } + + /** + * Whether [block] is the selected event. Matched on the series and start too, + * since the provider regenerates `Instances` rows and a re-read can bring the + * same occurrence back under a new instance id. + */ + fun isSelected(block: TimedBlock): Boolean { + val selected = selection?.event ?: return false + return block.event.instanceId == selected.instanceId || + (block.event.eventId == selected.eventId && block.event.start == selected.start) + } + /** Whether a finger is on a block right now — [settling] is not dragging. */ var isDragging: Boolean by mutableStateOf(false) private set @@ -370,6 +401,9 @@ class TimelineDragController { pointerInRoot: Offset, blockInRoot: Offset, ) { + // A pickup ends any selection: a drop moves the event out from under + // it, and a release without moving selects again. + selection = null source = block this.clipOffsetMin = clipOffsetMin this.titleLines = titleLines @@ -880,3 +914,27 @@ private fun DragCopy( } } } + +/** How far a selected block is lifted, animated between 0 and 1 (#141). */ +@Composable +fun selectionLift(selected: Boolean): Float { + val reduceMotion = rememberReduceMotion() + return animateFloatAsState( + targetValue = if (selected) 1f else 0f, + animationSpec = if (reduceMotion) snap() else MaterialTheme.motionScheme.fastSpatialSpec(), + label = "selection-lift", + ).value +} + +/** + * A selected block raised off the grid, the way a dragged copy is: scaled from + * its top edge so the text doesn't creep, with a shadow in the block's shape. + */ +fun Modifier.selectionLift(raise: Float, shape: Shape): Modifier = graphicsLayer { + transformOrigin = TransformOrigin(0.5f, 0f) + scaleX = 1f + 0.02f * raise + scaleY = 1f + 0.02f * raise + shadowElevation = 6.dp.toPx() * raise + this.shape = shape + clip = false +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index abc49568..326d7b72 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -59,6 +59,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow +import androidx.activity.compose.BackHandler +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.zIndex +import de.jeanlucmakiola.calendula.ui.common.PickUp +import de.jeanlucmakiola.calendula.ui.common.PublishSelection +import de.jeanlucmakiola.calendula.ui.common.eventSelectAllowed +import de.jeanlucmakiola.calendula.ui.common.selectionLift import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier @@ -521,6 +529,20 @@ private fun WeekContent( ) } + // A long-pressed event stays selected (#141) until something else is touched: + // back, another page, a view switch, or any tap. + val selection = dragController.selection + PublishSelection(selection?.event, onDismiss = dragController::deselect) + BackHandler(enabled = selection != null) { dragController.deselect() } + LaunchedEffect(pagerState, dragController) { + snapshotFlow { pagerState.currentPage }.drop(1).collect { dragController.deselect() } + } + LaunchedEffect(morph != null) { if (morph != null) dragController.deselect() } + val openEvent: (EventInstance) -> Unit = { event -> + dragController.deselect() + onEventClick(event) + } + Box(modifier = modifier) { CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) { // Loading and failure concern the provider as a whole, so they stand in @@ -541,7 +563,7 @@ private fun WeekContent( today = today, scrollState = scrollState, dragController = dragController, - onEventClick = onEventClick, + onEventClick = openEvent, onOpenDay = onOpenDay, onCreateAt = onCreateAt, onDrop = onDrop, @@ -555,7 +577,7 @@ private fun WeekContent( initialStripHeight = handoffStrip ?: s.allDayStripHeight(), scrollState = scrollState, dragController = dragController, - onEventClick = onEventClick, + onEventClick = openEvent, onOpenDay = onOpenDay, onCreateAt = onCreateAt, onDrop = onDrop, @@ -1324,6 +1346,10 @@ private fun DayColumnCard( // blocks are consumed by their own handler first. Snaps to hour. .pointerInput(date) { detectTapGestures { offset -> + if (dragController.selection != null) { + dragController.deselect() + return@detectTapGestures + } if (currentEdges.value.tap(offset.y)) return@detectTapGestures currentOnCreateAt.value( date, @@ -1481,36 +1507,54 @@ private fun EventBlock( val zone = remember { TimeZone.currentSystemDefault() } val moveAction = eventMoveAction(block.event) val draggable = eventDragAllowed(block.event) + val selectable = eventSelectAllowed() + val selected = dragController.isSelected(block) + val haptics = LocalHapticFeedback.current // The drop takes this offset back off, so a tail clipped at midnight lands // where the event's own start belongs (#253). val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) } val topInsetPx = with(density) { topInset.toPx() } + // A long press lifts the block (#141): moved, it drags; let go where it was + // picked up, it stays selected. A block that can't move is selected on the + // spot. Once selected, it drags without another hold. val dragModifier = rememberEventDragSource( - enabled = draggable, + enabled = selectable, key = block.event.instanceId, + immediate = selected && draggable, onPickUp = { pointer, blockRoot, _ -> - dragController.begin( - block = block, - clipOffsetMin = clipOffset, - titleLines = if (showTitle) titleMaxLines else 0, - pointerInRoot = pointer, - // Measured off the block's placement rather than off where the - // hour grid seats it: the copy is drawn from that placement and - // re-seated the same half-gap down (#267). - blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx), - ) + if (draggable) { + dragController.begin( + block = block, + clipOffsetMin = clipOffset, + titleLines = if (showTitle) titleMaxLines else 0, + pointerInRoot = pointer, + // Measured off the block's placement rather than off where the + // hour grid seats it: the copy is drawn from that placement and + // re-seated the same half-gap down (#267). + blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx), + ) + PickUp.Drag + } else { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + dragController.select(block.event, date) + PickUp.Hold + } }, onMove = dragController::move, onDrop = { dragController.finish()?.let(onDrop) }, onCancel = dragController::cancel, + onRelease = { dragController.select(block.event, date) }, ) val lifted = draggable && dragController.ghosts(block) val ghost = ghostAlpha(lifted) + val raise = selectionLift(selected) Box( - modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) + modifier = (if (dimmed && !selected) modifier.alpha(EventDimAlpha) else modifier) + .zIndex(if (selected) 1f else 0f) // The source stays put as a ghost while its floating copy travels. .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) + .then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier) .eventSurface(paint, shape, cuts) .clickable(onClick = onClick) // After clickable, so it is the inner node and wins the main pass; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index baba48c5..4cc941bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,6 +99,7 @@ Moved to %1$s Changed to %1$s Change recurring event + Couldn\'t open the event Undo Move undone Couldn\'t move the event