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(
movableCalendarIds = movableCalendarIds,
move = reschedule::move,
inFlight = reschedule.inFlight,
edit = onEditEvent,
)
}

View File

@@ -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<MoveUndo?>(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,
)
}
}
}
}
}
}

View File

@@ -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<Long>,
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. */
val edit: (EventInstance) -> Unit,
) {
@@ -35,9 +46,36 @@ class EventMoveScope(
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. */
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

View File

@@ -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<Boolean> = _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

View File

@@ -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<LocalDate, Int>? = 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
}

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.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;

View File

@@ -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

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.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 {

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.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;