Select timeline events with a long press and act on them from a bar (#141)

This commit is contained in:
2026-10-09 11:00:44 +02:00
parent a48becefc0
commit 44f91a963a
11 changed files with 636 additions and 46 deletions
@@ -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.
@@ -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<ScreenChrome?>(null)
private set
var selection by mutableStateOf<EventSelection?>(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() },
)
}
}
/**
@@ -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<Map<Long, CalendarSource>> = 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<DeletePrompt?>(null)
val deletePrompt: StateFlow<DeletePrompt?> = _deletePrompt.asStateFlow()
private val _failure = MutableStateFlow<EventActionFailure?>(null)
val failure: StateFlow<EventActionFailure?> = _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
}
}
@@ -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()
}
}
}
}
@@ -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,
) {
@@ -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.
@@ -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))
}
}
@@ -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<EventSelection?>(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,
)
}
}
@@ -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<LocalDate, Int> 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<Long, Instant>? 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
}
@@ -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;
+1
View File
@@ -99,6 +99,7 @@
<string name="event_move_done">Moved to %1$s</string>
<string name="event_resize_done">Changed to %1$s</string>
<string name="event_resize_recurring_title">Change recurring event</string>
<string name="event_action_load_failed">Couldn\'t open the event</string>
<string name="event_move_undo">Undo</string>
<string name="event_move_undone">Move undone</string>
<string name="event_move_failed">Couldn\'t move the event</string>