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 43f9c03..8052f2c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -316,6 +316,7 @@ fun CalendarHost( EventMoveScope( movableCalendarIds = movableCalendarIds, move = reschedule::move, + inFlight = reschedule.inFlight, edit = onEditEvent, ) } 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 d7b1a18..3b67f95 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 @@ -1,44 +1,71 @@ 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.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import kotlinx.coroutines.delay import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset import java.util.Locale +/** How long the confirmation chip stays up, matching a short snackbar. */ +private const val CHIP_MILLIS = 4_000L + +/** The FAB's own band at the bottom end, which the chip must not run into. */ +private val FAB_BAND = 88.dp + +private val CHIP_HEIGHT = 56.dp + /** * The two surfaces a drag-and-drop reschedule needs on top of the calendar: the - * recurring-scope prompt, and the confirmation snackbar carrying Undo. + * recurring-scope prompt, and the confirmation chip carrying Undo. * * None of the four calendar screens sets a `snackbarHost` on its Scaffold, so - * this hosts its own — bottom-centre inside the host's root Box, above every - * view. It forgoes the Scaffold's FAB avoidance in exchange for not threading a - * `SnackbarHostState` through all four screens. + * this hosts its own confirmation — a pill on the FAB's own band at the bottom + * start rather than a full-width bar, so the calendar it confirms a change to + * stays visible behind it. */ @Composable fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) { val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle() val outcome by viewModel.outcome.collectAsStateWithLifecycle() - val snackbars = remember { SnackbarHostState() } val locale = currentLocale() val use24Hour = LocalUse24HourFormat.current @@ -64,29 +91,97 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end) MoveOutcome.Failed -> stringResource(R.string.event_move_failed) } - val undoLabel = stringResource(R.string.event_move_undo) + // Held past the outcome being consumed so the chip has something to draw + // while it slides back out. + var shownMessage by remember { mutableStateOf("") } + var shownUndo by remember { mutableStateOf(null) } LaunchedEffect(outcome) { val text = message ?: return@LaunchedEffect - val undo = moved?.undo - val result = snackbars.showSnackbar( - message = text, - actionLabel = undoLabel.takeIf { undo != null }, - withDismissAction = undo == null, - duration = SnackbarDuration.Short, - ) - if (result == SnackbarResult.ActionPerformed && undo != null) { - viewModel.undo(undo) - } else { - viewModel.consumeOutcome() - } + shownMessage = text + shownUndo = moved?.undo + delay(CHIP_MILLIS) + viewModel.consumeOutcome() } - Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter) { - SnackbarHost( - hostState = snackbars, - modifier = Modifier.navigationBarsPadding().padding(16.dp), - ) + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val chipMaxWidth = maxWidth - FAB_BAND + // A FAB-height band anchored at the bottom start with the FAB's own + // margin; centring the chip in it lines it up beside the bottom-end FAB + // at exactly its height, rather than sitting a touch above it. + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .padding(start = 16.dp, bottom = 16.dp) + .height(CHIP_HEIGHT), + contentAlignment = Alignment.CenterStart, + ) { + MoveChip( + visible = outcome != null, + message = shownMessage, + maxWidth = chipMaxWidth, + onUndo = shownUndo?.let { undo -> { viewModel.undo(undo) } }, + ) + } + } +} + +/** + * The confirmation pill: sized to its content, sliding up from the bottom, with + * Undo where the write has a clean inverse. + */ +@Composable +private fun MoveChip( + visible: Boolean, + message: String, + maxWidth: Dp, + onUndo: (() -> Unit)?, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(50), + shadowElevation = 6.dp, + modifier = Modifier.widthIn(max = maxWidth), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding( + start = 20.dp, + end = if (onUndo != null) 8.dp else 20.dp, + top = 6.dp, + bottom = 6.dp, + ), + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f, fill = false) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) + if (onUndo != null) { + TextButton( + onClick = onUndo, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = stringResource(R.string.event_move_undo), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } } } 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 3b1caf3..554c4a2 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 @@ -1,13 +1,17 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by @@ -27,6 +31,13 @@ class EventMoveScope( */ val movableCalendarIds: Set, val move: (MoveRequest) -> Unit, + /** + * True while a dropped event is being written — including the time its scope + * dialog is up. A flow rather than a value so this scope stays the same + * object across a move: it is a composition local every visible block reads, + * and replacing it would recompose all of them twice per drop. + */ + val inFlight: StateFlow, /** Open an event in the edit form — the pointer-free route to the same change. */ val edit: (EventInstance) -> Unit, ) { @@ -35,9 +46,36 @@ class EventMoveScope( val LocalEventMove = compositionLocalOf { null } +private val NEVER_IN_FLIGHT = MutableStateFlow(false) + +/** + * Whether a dropped event is still being written — false wherever moving is off. + * The drag overlays hold a landed block on its target for this window. + */ +@Composable +fun moveInFlight(): Boolean { + val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT + return flow.collectAsStateWithLifecycle().value +} + /** Opacity the source block keeps while its floating copy travels. */ const val GHOST_ALPHA: Float = 0.3f +/** + * Opacity for a block whose copy is in flight: ghosted while the finger holds + * it, then faded out once the copy has landed, so the slot it came from is + * already empty by the time the grid re-reads the move. + */ +@Composable +fun ghostAlpha(lifted: Boolean, landed: Boolean): Float = animateFloatAsState( + targetValue = when { + !lifted -> 1f + landed -> 0f + else -> GHOST_ALPHA + }, + label = "ghost-alpha", +).value + /** * A TalkBack action that opens [event] in the edit form, so rescheduling isn't * pointer-only. Null when this event can't be moved — the block then carries no diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index e022b6f..ba5868d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -133,6 +133,15 @@ class RescheduleViewModel @Inject constructor( private var pending: PreparedMove? = null + private val _inFlight = MutableStateFlow(false) + + /** + * True from the moment a drop is accepted until its write settles — the + * window the dropped block holds its landing position for, rather than + * snapping back to where it came from until the grid re-reads it. + */ + val inFlight: StateFlow = _inFlight.asStateFlow() + /** * Set from the moment a drop is accepted until its write settles. Two drops * of the same recurring event landing inside that window would each compute @@ -140,6 +149,10 @@ class RescheduleViewModel @Inject constructor( * both to the re-read anchor — so the shifts would compound. */ private var busy = false + set(value) { + field = value + _inFlight.value = value + } /** * The calendars whose events may be dragged. Nothing below the UI guards 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 0d898e0..bac2b14 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,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ScrollState import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.background @@ -41,6 +42,7 @@ import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale +import kotlinx.coroutines.delay import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime @@ -119,14 +121,28 @@ class TimelineDragController { var drag: TimelineDrag? by mutableStateOf(null) private set + /** + * A dropped block, held at the slot it landed on while the write runs. The + * grid behind it still shows the old time until the provider notifies and + * the query re-reads, so releasing the copy at drop time would snap the + * event back to where it came from for the length of the write. + */ + var settling: TimelineDrag? by mutableStateOf(null) + private set + /** * Which block is lifted, and whether anything is. Separate snapshot state * from [drag] on purpose: [drag] changes on every frame, and the blocks that - * only need to know "am I the ghost" must not recompose that often. + * only need to know "am I the ghost" must not recompose that often. Stays + * set through [settling], so the source never reappears under the copy. */ var liftedInstanceId: Long? by mutableStateOf(null) private set + /** Whether a finger is on a block right now — [settling] is not dragging. */ + var isDragging: Boolean by mutableStateOf(false) + private set + private var source: TimedBlock? = null private var grab = Offset.Zero private var pointer = Offset.Zero @@ -139,10 +155,10 @@ class TimelineDragController { */ private var originSlot: Pair? = null - val isDragging: Boolean get() = liftedInstanceId != null - fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) { source = block + settling = null + isDragging = true liftedInstanceId = block.event.instanceId grab = pointerInRoot - blockInRoot pointer = pointerInRoot @@ -158,23 +174,34 @@ class TimelineDragController { fun cancel() { source = null + isDragging = false liftedInstanceId = null originSlot = null drag = null + settling = null } /** * End the drag, handing back where it landed — null when it never resolved, - * or when it landed back on the slot it started from. + * or when it landed back on the slot it started from. A real drop keeps its + * copy on the target as [settling] until [release]. */ fun finish(): TimelineDrop? { val landed = drag val origin = originSlot cancel() if (landed == null || landed.slot == origin) return null + settling = landed + liftedInstanceId = landed.event.instanceId return TimelineDrop(landed.event, landed.date, landed.startMin) } + /** Hand a settled drop back to the grid, which by now draws it itself. */ + fun release() { + settling = null + liftedInstanceId = null + } + /** * Re-derive the target from the pointer's *root* position. Recomputed rather * than accumulated from `positionChange()`, because a stationary finger emits @@ -272,6 +299,13 @@ fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean = fun TimelineDrop.startInstant(zone: TimeZone): Instant = LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone) +/** + * How long a landed block keeps its position after the write reports back. The + * provider's change notification and the re-query land a beat later; releasing + * on the write alone would still flash the old slot. + */ +const val SETTLE_GRACE_MILLIS: Long = 450L + /** * The floating block, drawn over the whole calendar so it is free of the day * column's clip and of the scroll viewport's rounded corners. @@ -285,6 +319,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = val locale = currentLocale() val reduceMotion = rememberReduceMotion() val density = LocalDensity.current + val moveInFlight = moveInFlight() // Both live here rather than beside the controller: they read [drag], which // changes every frame, and this composable is the one that is meant to. @@ -292,6 +327,13 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = LaunchedEffect(controller.isDragging) { if (controller.isDragging) controller.autoScroll() } + // Hold the landed copy until the write is done — including the whole time a + // recurring drop's scope dialog is up — then a grace for the re-read. + LaunchedEffect(controller.settling, moveInFlight) { + if (controller.settling == null || moveInFlight) return@LaunchedEffect + delay(SETTLE_GRACE_MILLIS) + controller.release() + } Box( modifier = modifier @@ -301,7 +343,14 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = .clearAndSetSemantics { } .onGloballyPositioned { origin = it.positionInRoot() }, ) { - val drag = controller.drag ?: return@Box + val drag = controller.drag ?: controller.settling ?: return@Box + // Landed: the copy sinks back to the grid's own plane while the write + // runs, so the release reads as the block settling rather than vanishing. + val landed = controller.drag == null + val lift by animateFloatAsState( + targetValue = if (landed || reduceMotion) 0f else 1f, + label = "drag-lift", + ) val fill = eventFill(drag.event.color, dark, soften) val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" + @@ -322,11 +371,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = ) .padding(horizontal = 1.dp) .graphicsLayer { - if (!reduceMotion) { - scaleX = 1.02f - scaleY = 1.02f - } - shadowElevation = 8.dp.toPx() + scaleX = 1f + 0.02f * lift + scaleY = 1f + 0.02f * lift + shadowElevation = 8.dp.toPx() * lift shape = RoundedCornerShape(4.dp) clip = false } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 4778c80..0d64220 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -79,7 +79,7 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA +import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveTarget @@ -714,10 +714,12 @@ private fun EventBlock( onCancel = dragController::cancel, ) val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId + val ghost = ghostAlpha(lifted, landed = dragController.settling != null) Box( modifier = modifier - // The source stays put as a ghost while its floating copy travels. - .then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier) + // The source stays put as a ghost while its floating copy travels, + // then fades out as the copy settles on its new slot. + .then(if (lifted) Modifier.alpha(ghost) else Modifier) .background(fill, RoundedCornerShape(4.dp)) .clickable(onClick = onClick) // After clickable, so it is the inner node and wins the main pass; diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt index 6c735ee..ea64659 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt @@ -62,22 +62,34 @@ class MonthDragController { var drag: MonthChipDrag? by mutableStateOf(null) private set + /** + * A dropped chip, held on the day it landed on while the write runs. The grid + * behind it still shows the old day until the provider notifies and the query + * re-reads, so releasing at drop time would snap the event back for the + * length of the write. + */ + var settling: MonthChipDrag? by mutableStateOf(null) + private set + /** * Which chip is lifted, and whether anything is. Separate snapshot state from * [drag] on purpose: [drag] changes on every frame, while the chips and rows - * that only need "am I the ghost" / "must I stop clipping" must not. + * that only need "am I the ghost" / "must I stop clipping" must not. Stays + * set through [settling], so the source never reappears under the copy. */ var liftedInstanceId: Long? by mutableStateOf(null) private set + /** Whether a finger is on a chip right now — [settling] is not dragging. */ + var isDragging: Boolean by mutableStateOf(false) + private set + private var event: EventInstance? = null private var grabDate: LocalDate? = null private var grab = Offset.Zero private var pointer = Offset.Zero private var sizePx = IntSize.Zero - val isDragging: Boolean get() = liftedInstanceId != null - fun putRow(token: Any, geometry: MonthRowGeometry) { rows[token] = geometry } @@ -95,6 +107,8 @@ class MonthDragController { ) { this.event = event this.grabDate = grabDate + settling = null + isDragging = true liftedInstanceId = event.instanceId grab = pointerInRoot - chipInRoot pointer = pointerInRoot @@ -110,17 +124,44 @@ class MonthDragController { fun cancel() { event = null grabDate = null + isDragging = false liftedInstanceId = null drag = null + settling = null } + /** + * End the drag. A real drop keeps its chip on the target day as [settling] + * until [release] — slid over to that day's column, so it lands where the + * grid is about to draw it rather than wherever the finger happened to be. + */ fun finish(): MonthChipDrop? { val landed = drag + val left = landed?.targetDate?.let(::columnLeft) cancel() val target = landed?.targetDate ?: return null + settling = landed.copy( + topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y), + ) + liftedInstanceId = landed.event.instanceId return MonthChipDrop(landed.event, landed.grabDate, target) } + /** Hand a settled drop back to the grid, which by now draws it itself. */ + fun release() { + settling = null + liftedInstanceId = null + } + + /** Root x of [date]'s column, in whichever visible row shows that day. */ + private fun columnLeft(date: LocalDate): Float? = rows.values.firstNotNullOfOrNull { row -> + val index = row.days.indexOf(date).takeIf { it >= 0 } ?: return@firstNotNullOfOrNull null + val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot() + ?: return@firstNotNullOfOrNull null + val column = if (row.isRtl) row.days.lastIndex - index else index + bounds.left + column * row.columnWidthPx + } + private fun recompute() { val event = event ?: return val grabbed = grabDate ?: return diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 85557d5..364aaf4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -89,8 +89,10 @@ import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.LocalEventMove -import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA +import de.jeanlucmakiola.calendula.ui.common.moveInFlight +import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics +import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.domain.spanFirstDay import androidx.compose.ui.unit.IntSize @@ -148,6 +150,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch @@ -470,9 +473,17 @@ private fun MonthDragOverlay(controller: MonthDragController) { val dark = isSystemInDarkTheme() val density = LocalDensity.current val reduceMotion = rememberReduceMotion() + val moveInFlight = moveInFlight() // Here rather than beside the controller: this reads [drag], which changes // every frame, and this composable is the one that is meant to. DragSnapHaptics(controller.drag?.targetDate) + // Hold the landed chip until the write is done — including the whole time a + // recurring drop's scope dialog is up — then a grace for the re-read. + LaunchedEffect(controller.settling, moveInFlight) { + if (controller.settling == null || moveInFlight) return@LaunchedEffect + delay(SETTLE_GRACE_MILLIS) + controller.release() + } Box( modifier = Modifier .fillMaxSize() @@ -481,7 +492,13 @@ private fun MonthDragOverlay(controller: MonthDragController) { .clearAndSetSemantics { } .onGloballyPositioned { origin = it.positionInRoot() }, ) { - val drag = controller.drag ?: return@Box + val drag = controller.drag ?: controller.settling ?: return@Box + // Landed: the copy sinks back to the grid's own plane while the write + // runs, so the release reads as the chip settling rather than vanishing. + val lift by animateFloatAsState( + targetValue = if (controller.drag == null || reduceMotion) 0f else 1f, + label = "chip-lift", + ) MonthBar( event = drag.event, dark = dark, @@ -500,11 +517,9 @@ private fun MonthDragOverlay(controller: MonthDragController) { .height(with(density) { drag.sizePx.height.toDp() }) .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp) .graphicsLayer { - if (!reduceMotion) { - scaleX = 1.04f - scaleY = 1.04f - } - shadowElevation = 8.dp.toPx() + scaleX = 1f + 0.04f * lift + scaleY = 1f + 0.04f * lift + shadowElevation = 8.dp.toPx() * lift shape = RoundedCornerShape(4.dp) clip = false }, @@ -2247,8 +2262,11 @@ private fun MonthBar( val soften = LocalSoftenColors.current val fill = eventFill(event.color, dark, soften) val moveAction = eventMoveAction(event) - // The source stays put as a ghost while its floating copy travels. - val lifted = LocalMonthDrag.current?.liftedInstanceId == event.instanceId + // The source stays put as a ghost while its floating copy travels, then + // fades out as the copy settles on its new day. + val monthDrag = LocalMonthDrag.current + val lifted = monthDrag?.liftedInstanceId == event.instanceId + val ghost = ghostAlpha(lifted, landed = monthDrag?.settling != null) val shape = RoundedCornerShape( topStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp, @@ -2257,7 +2275,7 @@ private fun MonthBar( ) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) - .then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier) + .then(if (lifted) Modifier.alpha(ghost) else Modifier) .background(fill, shape) .padding(horizontal = 4.dp) .semantics { 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 9808f1e..c189e3c 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 @@ -88,7 +88,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha -import de.jeanlucmakiola.calendula.ui.common.GHOST_ALPHA +import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveTarget @@ -883,10 +883,12 @@ private fun EventBlock( onCancel = dragController::cancel, ) val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId + val ghost = ghostAlpha(lifted, landed = dragController.settling != null) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) - // The source stays put as a ghost while its floating copy travels. - .then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier) + // The source stays put as a ghost while its floating copy travels, + // then fades out as the copy settles on its new slot. + .then(if (lifted) Modifier.alpha(ghost) else Modifier) .background(fill, RoundedCornerShape(4.dp)) .clickable(onClick = onClick) // After clickable, so it is the inner node and wins the main pass;