Land dropped events in place, and confirm with a chip (#68)

The floating copy used to vanish the moment the finger lifted, so the block
snapped back to its old slot until the provider notified and the query
re-read it. It now holds the slot it landed on until the write settles,
sinking its lift as it does, while the source ghost fades out.

The confirmation moves from a full-width snackbar to a pill on the FAB's
band, the shape agendula uses for its undoable delete.
This commit is contained in:
2026-08-02 17:59:25 +02:00
parent 19b86936e6
commit 5eb7d76f8a
9 changed files with 314 additions and 57 deletions

View File

@@ -316,6 +316,7 @@ fun CalendarHost(
EventMoveScope( EventMoveScope(
movableCalendarIds = movableCalendarIds, movableCalendarIds = movableCalendarIds,
move = reschedule::move, move = reschedule::move,
inFlight = reschedule.inFlight,
edit = onEditEvent, edit = onEditEvent,
) )
} }

View File

@@ -1,44 +1,71 @@
package de.jeanlucmakiola.calendula.ui.common 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.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.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarDuration import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.SnackbarHost import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.coroutines.delay
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.Locale 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 * 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 * 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 * this hosts its own confirmation — a pill on the FAB's own band at the bottom
* view. It forgoes the Scaffold's FAB avoidance in exchange for not threading a * start rather than a full-width bar, so the calendar it confirms a change to
* `SnackbarHostState` through all four screens. * stays visible behind it.
*/ */
@Composable @Composable
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) { fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle() val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
val outcome by viewModel.outcome.collectAsStateWithLifecycle() val outcome by viewModel.outcome.collectAsStateWithLifecycle()
val snackbars = remember { SnackbarHostState() }
val locale = currentLocale() val locale = currentLocale()
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
@@ -64,30 +91,98 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end) MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
MoveOutcome.Failed -> stringResource(R.string.event_move_failed) 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<MoveUndo?>(null) }
LaunchedEffect(outcome) { LaunchedEffect(outcome) {
val text = message ?: return@LaunchedEffect val text = message ?: return@LaunchedEffect
val undo = moved?.undo shownMessage = text
val result = snackbars.showSnackbar( shownUndo = moved?.undo
message = text, delay(CHIP_MILLIS)
actionLabel = undoLabel.takeIf { undo != null },
withDismissAction = undo == null,
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed && undo != null) {
viewModel.undo(undo)
} else {
viewModel.consumeOutcome() viewModel.consumeOutcome()
} }
}
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
SnackbarHost( val chipMaxWidth = maxWidth - FAB_BAND
hostState = snackbars, // A FAB-height band anchored at the bottom start with the FAB's own
modifier = Modifier.navigationBarsPadding().padding(16.dp), // 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,
)
}
}
}
}
}
} }
/** /**

View File

@@ -1,13 +1,17 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance 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 * Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
@@ -27,6 +31,13 @@ class EventMoveScope(
*/ */
val movableCalendarIds: Set<Long>, val movableCalendarIds: Set<Long>,
val move: (MoveRequest) -> Unit, 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<Boolean>,
/** Open an event in the edit form — the pointer-free route to the same change. */ /** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit, val edit: (EventInstance) -> Unit,
) { ) {
@@ -35,9 +46,36 @@ class EventMoveScope(
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null } val LocalEventMove = compositionLocalOf<EventMoveScope?> { 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. */ /** Opacity the source block keeps while its floating copy travels. */
const val GHOST_ALPHA: Float = 0.3f 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 * 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 * pointer-only. Null when this event can't be moved — the block then carries no

View File

@@ -133,6 +133,15 @@ class RescheduleViewModel @Inject constructor(
private var pending: PreparedMove? = null 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<Boolean> = _inFlight.asStateFlow()
/** /**
* Set from the moment a drop is accepted until its write settles. Two drops * 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 * 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. * both to the re-read anchor — so the shifts would compound.
*/ */
private var busy = false private var busy = false
set(value) {
field = value
_inFlight.value = value
}
/** /**
* The calendars whose events may be dragged. Nothing below the UI guards * The calendars whose events may be dragged. Nothing below the UI guards

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.background 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.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
@@ -119,14 +121,28 @@ class TimelineDragController {
var drag: TimelineDrag? by mutableStateOf(null) var drag: TimelineDrag? by mutableStateOf(null)
private set 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 * Which block is lifted, and whether anything is. Separate snapshot state
* from [drag] on purpose: [drag] changes on every frame, and the blocks that * 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) var liftedInstanceId: Long? by mutableStateOf(null)
private set 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 source: TimedBlock? = null
private var grab = Offset.Zero private var grab = Offset.Zero
private var pointer = Offset.Zero private var pointer = Offset.Zero
@@ -139,10 +155,10 @@ class TimelineDragController {
*/ */
private var originSlot: Pair<LocalDate, Int>? = null private var originSlot: Pair<LocalDate, Int>? = null
val isDragging: Boolean get() = liftedInstanceId != null
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) { fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
source = block source = block
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot grab = pointerInRoot - blockInRoot
pointer = pointerInRoot pointer = pointerInRoot
@@ -158,23 +174,34 @@ class TimelineDragController {
fun cancel() { fun cancel() {
source = null source = null
isDragging = false
liftedInstanceId = null liftedInstanceId = null
originSlot = null originSlot = null
drag = null drag = null
settling = null
} }
/** /**
* End the drag, handing back where it landed — null when it never resolved, * 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? { fun finish(): TimelineDrop? {
val landed = drag val landed = drag
val origin = originSlot val origin = originSlot
cancel() cancel()
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
settling = landed
liftedInstanceId = landed.event.instanceId
return TimelineDrop(landed.event, landed.date, landed.startMin) 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 * Re-derive the target from the pointer's *root* position. Recomputed rather
* than accumulated from `positionChange()`, because a stationary finger emits * 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 = fun TimelineDrop.startInstant(zone: TimeZone): Instant =
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone) 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 * 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. * 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 locale = currentLocale()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
val density = LocalDensity.current val density = LocalDensity.current
val moveInFlight = moveInFlight()
// Both live here rather than beside the controller: they read [drag], which // 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. // 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) { LaunchedEffect(controller.isDragging) {
if (controller.isDragging) controller.autoScroll() 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( Box(
modifier = modifier modifier = modifier
@@ -301,7 +343,14 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
.clearAndSetSemantics { } .clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() }, .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 fill = eventFill(drag.event.color, dark, soften)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}" + val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}" +
@@ -322,11 +371,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
) )
.padding(horizontal = 1.dp) .padding(horizontal = 1.dp)
.graphicsLayer { .graphicsLayer {
if (!reduceMotion) { scaleX = 1f + 0.02f * lift
scaleX = 1.02f scaleY = 1f + 0.02f * lift
scaleY = 1.02f shadowElevation = 8.dp.toPx() * lift
}
shadowElevation = 8.dp.toPx()
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp)
clip = false clip = false
} }

View File

@@ -79,7 +79,7 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS 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.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.MoveTarget
@@ -714,10 +714,12 @@ private fun EventBlock(
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
val ghost = ghostAlpha(lifted, landed = dragController.settling != null)
Box( Box(
modifier = modifier modifier = modifier
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels,
.then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier) // then fades out as the copy settles on its new slot.
.then(if (lifted) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;

View File

@@ -62,22 +62,34 @@ class MonthDragController {
var drag: MonthChipDrag? by mutableStateOf(null) var drag: MonthChipDrag? by mutableStateOf(null)
private set 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 * 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 * [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) var liftedInstanceId: Long? by mutableStateOf(null)
private set 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 event: EventInstance? = null
private var grabDate: LocalDate? = null private var grabDate: LocalDate? = null
private var grab = Offset.Zero private var grab = Offset.Zero
private var pointer = Offset.Zero private var pointer = Offset.Zero
private var sizePx = IntSize.Zero private var sizePx = IntSize.Zero
val isDragging: Boolean get() = liftedInstanceId != null
fun putRow(token: Any, geometry: MonthRowGeometry) { fun putRow(token: Any, geometry: MonthRowGeometry) {
rows[token] = geometry rows[token] = geometry
} }
@@ -95,6 +107,8 @@ class MonthDragController {
) { ) {
this.event = event this.event = event
this.grabDate = grabDate this.grabDate = grabDate
settling = null
isDragging = true
liftedInstanceId = event.instanceId liftedInstanceId = event.instanceId
grab = pointerInRoot - chipInRoot grab = pointerInRoot - chipInRoot
pointer = pointerInRoot pointer = pointerInRoot
@@ -110,17 +124,44 @@ class MonthDragController {
fun cancel() { fun cancel() {
event = null event = null
grabDate = null grabDate = null
isDragging = false
liftedInstanceId = null liftedInstanceId = null
drag = 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? { fun finish(): MonthChipDrop? {
val landed = drag val landed = drag
val left = landed?.targetDate?.let(::columnLeft)
cancel() cancel()
val target = landed?.targetDate ?: return null 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) 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() { private fun recompute() {
val event = event ?: return val event = event ?: return
val grabbed = grabDate ?: return val grabbed = grabDate ?: return

View File

@@ -89,8 +89,10 @@ import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove 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.DragSnapHaptics
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanFirstDay
import androidx.compose.ui.unit.IntSize 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.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -470,9 +473,17 @@ private fun MonthDragOverlay(controller: MonthDragController) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val density = LocalDensity.current val density = LocalDensity.current
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
val moveInFlight = moveInFlight()
// Here rather than beside the controller: this reads [drag], which changes // Here rather than beside the controller: this reads [drag], which changes
// every frame, and this composable is the one that is meant to. // every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.targetDate) 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -481,7 +492,13 @@ private fun MonthDragOverlay(controller: MonthDragController) {
.clearAndSetSemantics { } .clearAndSetSemantics { }
.onGloballyPositioned { origin = it.positionInRoot() }, .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( MonthBar(
event = drag.event, event = drag.event,
dark = dark, dark = dark,
@@ -500,11 +517,9 @@ private fun MonthDragOverlay(controller: MonthDragController) {
.height(with(density) { drag.sizePx.height.toDp() }) .height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp) .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
.graphicsLayer { .graphicsLayer {
if (!reduceMotion) { scaleX = 1f + 0.04f * lift
scaleX = 1.04f scaleY = 1f + 0.04f * lift
scaleY = 1.04f shadowElevation = 8.dp.toPx() * lift
}
shadowElevation = 8.dp.toPx()
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp)
clip = false clip = false
}, },
@@ -2247,8 +2262,11 @@ private fun MonthBar(
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften) val fill = eventFill(event.color, dark, soften)
val moveAction = eventMoveAction(event) val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels, then
val lifted = LocalMonthDrag.current?.liftedInstanceId == event.instanceId // 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( val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp, topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -2257,7 +2275,7 @@ private fun MonthBar(
) )
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) 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) .background(fill, shape)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.semantics { .semantics {

View File

@@ -88,7 +88,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha 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.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.MoveTarget
@@ -883,10 +883,12 @@ private fun EventBlock(
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
val ghost = ghostAlpha(lifted, landed = dragController.settling != null)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels,
.then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier) // then fades out as the copy settles on its new slot.
.then(if (lifted) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp)) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;