Compare commits

...

9 Commits

Author SHA1 Message Date
5b4c32aff9 Take the undone chip away sooner (#68)
It offers nothing to act on and confirms a change the user just asked
for and can see behind it, so 4s was the chip outstaying what it said.
2026-08-02 19:11:39 +02:00
e13b631e10 Carry a month chip back on undo (#68)
Undo moves the event just as the drop did, so it now travels the same
way instead of the chip vanishing off the new day and reappearing on the
old one. The write signals when it starts, the controller puts a copy on
the chip's current seat, and the settle that follows is the drop's own —
glide onto the restored seat, ghost underneath, hand over there.

A week row now publishes what it draws along with where it draws it, so
the controller can find any moved event's seat itself rather than each
row scanning its own lanes.
2026-08-02 19:09:26 +02:00
74780cc212 Land a month chip on its seat instead of waiting it out (#68)
The month grid seats a dropped chip in a lane, but the copy was left at
whatever height the finger was at for a flat 450ms hold and then
cross-faded across that gap — long enough that the event looked like it
took its time arriving. The week row now reports where the grid actually
seats the drop once the re-read lands, and the copy glides onto it and
hands over there. The flat wait is only the fallback for a drop with no
seat to find (a day's "+N" overflow, or a failed write).
2026-08-02 18:56:23 +02:00
1285bbbf47 Crossfade a block's time label (#68)
The block keeps its identity across a move and slides to the new slot, so
its label was the one thing left changing in a single frame.
2026-08-02 18:43:02 +02:00
4412038caa Stop the moved event flickering once it lands (#68)
Two causes, both leaving the block at the old slot or dimmed for a frame or
several. The source is now identified for the write's duration by event row
plus the slot it left, not by instance id — the provider renumbers Instances
rows, so a re-read still carrying the old time arrives under a new id and
the ghost stopped matching. And the ghost is dropped when the copy *starts*
dissolving rather than after: un-ghosting last left the block dim under the
fading copy and brightened it once the copy was gone.
2026-08-02 18:39:38 +02:00
1c9a4f7f50 Spring the confirmation chip, and build it a frame earlier (#68)
The chip springs open from its corner on the expressive fast specs instead
of sliding in clipped, and its text is resolved in composition rather than
from an effect — a frame late meant it opened carrying the previous message
and grew into the new one mid-animation.

Undo now leaves the outcome standing until its write reports back, so the
one chip changes what it says instead of closing and reopening.
2026-08-02 18:27:44 +02:00
b7e624877d Revert the chip's FAB colours, quicken it, and end the settle flicker (#68)
The chip goes back to the neutral fill and its motion is cut to a receipt's
length — the default visibility spring overshot and read as sluggish.

The landed copy is now handed back when the grid actually draws the drop,
not on a timer: releasing early put the source ghost back at full opacity in
its old slot for the rest of the re-read, which is the flicker.
2026-08-02 18:19:43 +02:00
7a86014461 Move the chip into floret-kit, and tween the blocks (#68)
The confirmation pill is now floret-kit's SnackChip, shared with Agendula's
swipe-delete undo, wearing the FAB's colours and crossfading when its
message is answered.

Timed blocks are keyed by event and their bounds tweened, so a landing drop,
the neighbour that has to make room for it, and an undo all slide instead of
popping. The source ghost travels with them rather than fading out, and the
floating copy dissolves into the block once the grid has caught up.
2026-08-02 18:12:48 +02:00
5eb7d76f8a 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.
2026-08-02 17:59:25 +02:00
11 changed files with 776 additions and 123 deletions

View File

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

View File

@@ -0,0 +1,75 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/**
* A timed block's own time label, crossfaded rather than replaced. The block
* keeps its identity across a move and slides to the new slot; the label is the
* one thing on it that would otherwise change in a single frame.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
snap()
} else {
MaterialTheme.motionScheme.fastEffectsSpec()
}
Crossfade(
targetState = label,
animationSpec = spec,
label = "block-time",
modifier = modifier,
) { text ->
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = color,
)
}
}
/** Where a timed block sits in its column, after tweening. */
@Immutable
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
/**
* A timed block's placement, tweened rather than jumped. Every bound a block
* has changes for a reason the user just caused — a drop landing at a new time,
* an undo putting it back, a neighbour arriving and halving both lanes — and all
* of them read better as motion than as a new layout appearing.
*
* Continuity comes from the caller keying each block by identity; a block
* composed for the first time starts *at* its target, so nothing flies in from
* the corner on the first frame.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion()) {
snap()
} else {
MaterialTheme.motionScheme.fastSpatialSpec()
}
val animatedX by animateDpAsState(x, spec, label = "block-x")
val animatedY by animateDpAsState(y, spec, label = "block-y")
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
}

View File

@@ -1,44 +1,64 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
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.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin
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
/**
* How long an *undone* move stays up. Shorter than everything else the chip
* says: it offers nothing to act on, and it confirms a change the user has just
* asked for and can see on the grid behind it, so the full dwell is only the
* chip outstaying what it had to say.
*/
private const val UNDONE_CHIP_MILLIS = 1_600L
/** What the chip currently reads, kept past the outcome it was built from. */
private data class ChipContent(val message: String, val undo: MoveUndo?)
/** The FAB's own band at the bottom end, which the chip must not run into. */
private val FAB_BAND = 88.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 +84,44 @@ 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 springs back out. Updated *in composition* rather than from an
// effect: an effect lands a frame late, so the chip would open carrying the
// previous message and grow into this one while it was still animating in.
val shown = remember { mutableStateOf(ChipContent("", null)) }
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
shown.value = ChipContent(message, moved?.undo)
}
val content = shown.value
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()
}
if (outcome == null) return@LaunchedEffect
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else 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 = SnackChipMargin, bottom = SnackChipMargin)
.height(SnackChipHeight),
contentAlignment = Alignment.CenterStart,
) {
SnackChip(
visible = outcome != null,
message = content.message,
maxWidth = chipMaxWidth,
actionLabel = stringResource(R.string.event_move_undo)
.takeIf { content.undo != null },
onAction = content.undo?.let { undo -> { viewModel.undo(undo) } },
)
}
}
}

View File

@@ -1,13 +1,21 @@
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.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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 +35,15 @@ 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>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Int>,
/** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit,
) {
@@ -35,9 +52,53 @@ class EventMoveScope(
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
private val NEVER_UNDONE = MutableStateFlow(0)
/**
* 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
}
/**
* Runs [onUndo] when an undo write begins, and never for one that began before
* this composable came on screen — a view switched to *after* an undo has
* nothing left to carry back.
*/
@Composable
fun OnUndoStarted(onUndo: () -> Unit) {
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
val tick by flow.collectAsStateWithLifecycle()
var seen by remember { mutableIntStateOf(tick) }
LaunchedEffect(tick) {
if (tick == seen) return@LaunchedEffect
seen = tick
onUndo()
}
}
/** 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 from the lift until the
* copy is handed back, then animated up rather than switched. The ghost keeps
* its place through the write and *travels* to the new slot when the grid
* re-reads it, arriving under the copy as that fades — so what the eye follows
* is one block moving, not one vanishing and another appearing.
*/
@Composable
fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
targetValue = if (lifted) GHOST_ALPHA else 1f,
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,26 @@ 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()
private val _undoStarted = MutableStateFlow(0)
/**
* Ticks the moment an undo write begins — before the provider has anything to
* re-read. An undo moves an event exactly as a drop does, so the view that
* drew the drop takes this as its cue to carry the chip back rather than let
* it reappear on the old day. A counter rather than the undo itself: what a
* view needs is the *timing*, and it already knows what it moved.
*/
val undoStarted: StateFlow<Int> = _undoStarted.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 +160,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
@@ -213,11 +237,16 @@ class RescheduleViewModel @Inject constructor(
_scopePrompt.value = null
}
/** Put a completed move back where it came from. */
/**
* Put a completed move back where it came from. The outcome deliberately
* stands until the inverse write reports back: clearing it first would drop
* the confirmation chip and open a second one a moment later, rather than
* letting the one chip change what it says.
*/
fun undo(undo: MoveUndo) {
if (busy) return
busy = true
_outcome.value = null
_undoStarted.value += 1
viewModelScope.launch {
_outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored)

View File

@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.background
@@ -41,6 +43,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 +122,47 @@ 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
/**
* Where the settled drop came from, and which event row it belongs to. The
* instance id alone can't identify the source block for the length of the
* write: the provider regenerates `Instances` rows, so a re-read that still
* carries the *old* time can arrive under a new instance id — and a source
* matched by instance id would stop being the ghost and flash back to full
* opacity in the slot the event is about to leave.
*/
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
/** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false)
private set
/**
* Whether the grid itself now draws the settled drop at its landing slot.
* The copy may only be handed back once this is true: releasing on a timer
* puts the source ghost back at full opacity in its *old* slot for whatever
* is left of the re-read — a flicker of the event where it no longer is.
*/
var settledOnGrid: Boolean by mutableStateOf(false)
private set
private var source: TimedBlock? = null
private var grab = Offset.Zero
private var pointer = Offset.Zero
@@ -139,10 +175,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 +194,82 @@ class TimelineDragController {
fun cancel() {
source = null
isDragging = false
liftedInstanceId = null
originSlot = null
drag = null
settling = null
settledOnGrid = false
settledOrigin = null
}
/**
* Whether [block] is the one whose copy is in flight, and so must stay a
* ghost. While the finger holds it that is the instance it picked up; once
* dropped it is also whatever now sits in the slot it left, however the
* provider has renumbered it in the meantime.
*/
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
if (liftedInstanceId == null) return false
if (block.event.instanceId == liftedInstanceId) return true
val (eventId, originDate, originMin) = settledOrigin ?: return false
return block.event.eventId == eventId &&
date == originDate &&
block.startMin == originMin
}
/**
* What a day column now holds, so a settled drop can tell when the grid has
* caught up with it. Matched on the landing slot plus either the event row
* or its title: a single-occurrence move writes an exception row with a new
* `eventId`, which nothing else about the drop can predict.
*/
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
val landed = settling ?: return
if (settledOnGrid || landed.date != date) return
settledOnGrid = blocks.any { block ->
block.startMin == landed.startMin &&
(
block.event.eventId == landed.event.eventId ||
block.event.title == landed.event.title
)
}
}
/**
* 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
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
return TimelineDrop(landed.event, landed.date, landed.startMin)
}
/**
* Stop ghosting the source — the grid draws the drop itself by now — while
* the copy is still on screen dissolving. Un-ghosting only at [release]
* would leave the block under the fading copy dim, and brighten it once the
* copy was gone: a dip the eye reads as the event flickering.
*/
fun handOver() {
liftedInstanceId = null
settledOrigin = null
}
/** Drop the copy, once it has faded into the grid's own block. */
fun release() {
settling = null
settledOnGrid = false
handOver()
}
/**
* Re-derive the target from the pointer's *root* position. Recomputed rather
* than accumulated from `positionChange()`, because a stationary finger emits
@@ -272,6 +367,27 @@ fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
/**
* A beat after the grid draws the drop, so its own block is under way before the
* copy starts dissolving.
*/
const val SETTLE_GRACE_MILLIS: Long = 60L
/**
* How long to wait for a grid that never confirms the drop — the event landed on
* a day this timeline doesn't show, or the write failed and nothing changed. The
* copy has to go either way.
*/
private const val SETTLE_TIMEOUT_MILLIS = 900L
/**
* The hand-over: the copy dissolves over this while the grid's own block slides
* in under it, so the two overlap rather than one replacing the other. Kept
* short, because a full-width copy sits over any neighbour it now shares a lane
* with until it is gone.
*/
const val SETTLE_FADE_MILLIS: Int = 250
/**
* 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 +401,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 +409,18 @@ 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 — and then until the grid draws the
// drop itself, so the copy dissolves onto a block that is already there.
var handingOver by remember(controller.settling) { mutableStateOf(false) }
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
handingOver = true
controller.handOver()
delay(SETTLE_FADE_MILLIS.toLong())
controller.release()
}
Box(
modifier = modifier
@@ -301,7 +430,19 @@ 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 copyAlpha by animateFloatAsState(
targetValue = if (handingOver) 0f else 1f,
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover",
)
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 +463,10 @@ 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
alpha = copyAlpha
shape = RoundedCornerShape(4.dp)
clip = false
}

View File

@@ -42,6 +42,7 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -79,7 +80,9 @@ 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.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
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
@@ -613,6 +616,10 @@ private fun DayColumnCard(
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
}
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -640,25 +647,39 @@ private fun DayColumnCard(
) {
val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// the rare column holding two occurrences of one series, which would
// otherwise be two blocks under one key.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
EventBlock(
block = block,
dark = dark,
height = height,
date = date,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
.height(height)
.padding(horizontal = 1.dp),
)
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val laneWidth = colWidth / block.laneCount
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
val place = animatedBlockPlacement(
x = laneWidth * block.lane,
y = top,
width = laneWidth,
height = height,
)
EventBlock(
block = block,
dark = dark,
height = place.height,
date = date,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y)
.width(place.width)
.height(place.height)
.padding(horizontal = 1.dp),
)
}
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
@@ -713,11 +734,13 @@ private fun EventBlock(
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
val lifted = draggable && dragController.ghosts(block, date)
val ghost = ghostAlpha(lifted)
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 (ghost < 1f) 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;
@@ -740,11 +763,8 @@ private fun EventBlock(
)
}
if (showTime) {
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
BlockTimeLabel(
label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}

View File

@@ -10,23 +10,60 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import kotlin.math.abs
/** One week row's live geometry, republished on every layout while it is on screen. */
class MonthRowGeometry(
val days: List<LocalDate>,
/** The row's day-column box — the space chip offsets are measured in. */
val cell: LayoutCoordinates,
/** The lane band inside that box, where the chips themselves are seated. */
val band: LayoutCoordinates?,
val columnWidthPx: Float,
val laneHeightPx: Float,
val laneCount: Int,
/**
* Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored, but the grid is, so the leftmost column is the *last* day
* in Arabic.
*/
val isRtl: Boolean,
)
/** What this row currently draws in [lane] of column `col`, or null. */
val chipAt: (col: Int, lane: Int) -> EventInstance?,
) {
/** Root top-left of the chip seated in [lane] of column [col]. */
fun seat(col: Int, lane: Int): Offset? {
val band = band?.takeIf { it.isAttached } ?: return null
val origin = band.positionInRoot()
val column = if (isRtl) days.lastIndex - col else col
return Offset(origin.x + column * columnWidthPx, origin.y + lane * laneHeightPx)
}
/**
* Where this row seats [event] on [date], or null when it doesn't hold it —
* the day isn't in this week, or the chip went into the day's "+N" overflow.
*/
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
val lane = (0 until laneCount).firstOrNull { lane ->
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
} ?: return null
return seat(col, lane)
}
}
/**
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither
* id alone will do: the provider hands re-read instances new instance ids, and a
* single-occurrence move writes an exception row with a new event id — the title
* is what survives both.
*/
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
chip.eventId == moved.eventId || chip.title == moved.title
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
data class MonthChipDrag(
@@ -62,22 +99,61 @@ 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
/**
* Where the grid itself now draws the drop, in root coordinates — published
* by whichever week row ends up holding it. The copy is dropped wherever the
* finger was, but the grid seats it in a lane; without this the copy waits
* out a flat hold beside the chip the grid has already drawn and then
* cross-fades across the gap, which reads as the event taking its time to
* arrive. With it the copy glides onto its seat and hands over there.
*/
var settledInRoot: Offset? by mutableStateOf(null)
private set
/**
* Whether the drop's own chip is still standing in for the copy. Cleared at
* [handOver] rather than at [release], for the same reason [liftedInstanceId]
* is: held to the end it would keep the chip dim under a copy that has
* already faded, and brighten it afterwards — a dip the eye reads as the
* event flickering.
*/
private var settledGhost: Boolean by mutableStateOf(false)
/** Whether a finger is on a chip right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false)
private set
/**
* The last drop this controller made, kept past [release] for [beginUndo] —
* the confirmation chip carries Undo for four seconds after the copy is long
* gone. Not snapshot state: nothing draws from it.
*/
private var undoable: MonthChipDrag? = null
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 +171,8 @@ class MonthDragController {
) {
this.event = event
this.grabDate = grabDate
settling = null
isDragging = true
liftedInstanceId = event.instanceId
grab = pointerInRoot - chipInRoot
pointer = pointerInRoot
@@ -110,17 +188,112 @@ class MonthDragController {
fun cancel() {
event = null
grabDate = null
isDragging = false
liftedInstanceId = null
drag = null
settling = null
settledInRoot = null
settledGhost = false
}
/**
* Whether [days] holds the chip the grid has drawn for the settled drop —
* the seat the copy is on its way to, which must ghost until it gets there.
* Matched on the landing day plus either the event row or its title: a
* single-occurrence move writes an exception row with a new `eventId`, and
* the provider hands re-read instances new ids, so neither alone identifies
* the chip the drop became.
*/
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
val landed = settling?.takeIf { settledGhost } ?: return false
if (days == null || landed.targetDate !in days) return false
return isSameEvent(event, landed.event)
}
/**
* Ask the row keyed [token] whether it now seats the settled chip, and take
* its answer. The continuous style can show the same week twice, once in each
* adjoining month, so both copies of the landing row will answer: the seat
* nearest where the chip was let go is the one the finger was over.
*/
fun noteSettled(token: Any) {
val landed = settling ?: return
val target = landed.targetDate ?: return
val at = rows[token]?.seatOf(landed.event, target) ?: return
val current = settledInRoot
val closer = current == null ||
abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
if (closer) settledInRoot = at
}
/**
* Send a copy back for an undo, which moves the event exactly as the drop did
* and so should read the same way rather than teleporting the chip. Nothing
* here writes anything: it puts a chip on the journey the inverse write is
* about to make, and the settle that follows is the drop's own.
*
* False when there is nothing to carry — no drop of this controller's to undo
* (it happened in another view, or has already been undone), or the grid does
* not seat the moved event where it would have to start from. The chip then
* simply reappears on the day it came from, as it always did.
*/
fun beginUndo(): Boolean {
val last = undoable ?: return false
undoable = null
val from = last.targetDate ?: return false
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
liftedInstanceId = last.event.instanceId
settledGhost = true
settledInRoot = null
return true
}
/**
* 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
settledGhost = true
undoable = settling
return MonthChipDrop(landed.event, landed.grabDate, target)
}
/**
* Stop ghosting the source while the copy is still dissolving — un-ghosting
* only at [release] leaves the chip under it dim and then brightens it, a
* dip the eye reads as the event flickering.
*/
fun handOver() {
liftedInstanceId = null
settledGhost = false
}
/** Drop the copy, once it has faded into the grid's own chip. */
fun release() {
settling = null
settledInRoot = null
handOver()
}
/** 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

@@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.animateFloat
@@ -59,6 +61,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -89,8 +92,12 @@ 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.OnUndoStarted
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
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 +155,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
@@ -410,6 +418,10 @@ fun MonthScreen(
// overlay so it is free of the week row's clip and, in the scrolling
// styles, of the list viewport's.
val chipDrag = rememberMonthDragController()
// Undo moves the event back, so the chip travels back too rather than
// reappearing on its old day. The signal comes from the write, not
// from the chip that offers it: that lives above every view.
OnUndoStarted { chipDrag.beginUndo() }
Box(
modifier = Modifier
.padding(innerPadding)
@@ -463,6 +475,13 @@ fun MonthScreen(
}
}
/**
* How long to wait for a grid that never seats the drop — it landed in a day's
* "+N" overflow, or on a day this month doesn't show, or the write failed and
* nothing changed. The copy has to go either way.
*/
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
/** The chip in flight, drawn over the grid and following the finger. */
@Composable
private fun MonthDragOverlay(controller: MonthDragController) {
@@ -470,9 +489,36 @@ 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 — and then, unlike the timeline, carry
// it to its seat: a chip is dropped at whatever height the finger was at,
// while the grid seats it in a lane, so the copy has a gap to close before
// the two are the same chip and the hand-over can be invisible.
var handingOver by remember(controller.settling) { mutableStateOf(false) }
var gliding by remember(controller.settling) { mutableStateOf(false) }
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
val seat = controller.settledInRoot
LaunchedEffect(controller.settling, moveInFlight, seat) {
val landed = controller.settling
if (landed == null || moveInFlight) return@LaunchedEffect
if (seat == null) {
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
} else {
if (!gliding) glide.snapTo(landed.topLeftInRoot)
gliding = true
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
delay(SETTLE_GRACE_MILLIS)
}
handingOver = true
controller.handOver()
delay(SETTLE_FADE_MILLIS.toLong())
controller.release()
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -481,7 +527,18 @@ 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",
)
val copyAlpha by animateFloatAsState(
targetValue = if (handingOver) 0f else 1f,
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "chip-handover",
)
MonthBar(
event = drag.event,
dark = dark,
@@ -491,20 +548,20 @@ private fun MonthDragOverlay(controller: MonthDragController) {
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
val at = if (gliding) glide.value else drag.topLeftInRoot
IntOffset(
(drag.topLeftInRoot.x - origin.x).roundToInt(),
(drag.topLeftInRoot.y - origin.y).roundToInt(),
(at.x - origin.x).roundToInt(),
(at.y - origin.y).roundToInt(),
)
}
.width(with(density) { drag.sizePx.width.toDp() })
.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
alpha = copyAlpha
shape = RoundedCornerShape(4.dp)
clip = false
},
@@ -1809,6 +1866,7 @@ private fun MonthWeekRow(
val dragController = LocalMonthDrag.current
val rowToken = remember { Any() }
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val cellCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val density = LocalDensity.current
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@@ -1816,6 +1874,36 @@ private fun MonthWeekRow(
DisposableEffect(rowToken, dragController) {
onDispose { dragController?.removeRow(rowToken) }
}
// Republished on layout *and* on every recomposition: the coordinates only
// change on the former, but what the row draws — which the controller reads
// to find a moved chip's seat — changes on the latter, and a re-read that
// moves an event doesn't move the row it lands in.
val publish = {
val cell = cellCoordinates[0]?.takeIf { it.isAttached }
if (dragController != null && cell != null) {
dragController.putRow(
rowToken,
MonthRowGeometry(
days = week.days,
cell = cell,
band = bandCoordinates[0],
columnWidthPx = cell.size.width / 7f,
laneHeightPx = rowHeightPx,
laneCount = MAX_EVENT_ROWS,
isRtl = isRtl,
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
),
)
}
}
SideEffect { publish() }
// Once the grid holds the settled chip, tell the controller where this row
// has seated it, so the copy in flight can glide onto it. Keyed on the week
// because that is what changes when the re-read lands: until then the row
// still draws the event on the day it came from and answers nothing.
LaunchedEffect(week, dragController?.settling) {
dragController?.noteSettled(rowToken)
}
Row(modifier) {
// Optional calendar-week gutter, sized so the seven day columns below
@@ -1834,15 +1922,8 @@ private fun MonthWeekRow(
.weight(1f)
.fillMaxHeight()
.onGloballyPositioned { coords ->
dragController?.putRow(
rowToken,
MonthRowGeometry(
days = week.days,
cell = coords,
columnWidthPx = coords.size.width / 7f,
isRtl = isRtl,
),
)
cellCoordinates[0] = coords
publish()
}
.then(
monthChipDragModifier(
@@ -1918,7 +1999,10 @@ private fun MonthWeekRow(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.onGloballyPositioned { bandCoordinates[0] = it }
.onGloballyPositioned {
bandCoordinates[0] = it
publish()
}
// A dragged chip travels to another row, so the clip has
// to yield for it exactly as it does for a morph.
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
@@ -1931,6 +2015,7 @@ private fun MonthWeekRow(
dark = dark,
continuesLeft = span.continuesLeft,
continuesRight = span.continuesRight,
days = week.days.subList(span.startCol, span.endCol + 1),
modifier = Modifier
.offset(
x = colW * span.startCol,
@@ -2000,6 +2085,7 @@ private fun MonthWeekRow(
dark = dark,
continuesLeft = false,
continuesRight = false,
days = listOf(d),
modifier = Modifier
.offset(
x = colW * col,
@@ -2240,6 +2326,14 @@ private fun MonthBar(
continuesLeft: Boolean,
continuesRight: Boolean,
modifier: Modifier = Modifier,
/**
* The days this chip covers in its row, for the drag (#68). A drop's own chip
* has to ghost like the source it came from until the copy has landed on it,
* and the instance id can't find it: the provider hands the re-read instance a
* new one, so the chip the drop became would otherwise sit at full opacity
* under the copy still travelling towards it — the event drawn twice.
*/
days: List<LocalDate>? = null,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
@@ -2247,8 +2341,12 @@ 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 ||
monthDrag?.isSettledChip(event, days) == true
val ghost = ghostAlpha(lifted)
val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -2257,7 +2355,7 @@ private fun MonthBar(
)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (lifted) Modifier.alpha(GHOST_ALPHA) else Modifier)
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape)
.padding(horizontal = 4.dp)
.semantics {

View File

@@ -47,6 +47,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.key
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
@@ -88,7 +89,9 @@ 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.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
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
@@ -762,6 +765,10 @@ private fun DayColumnCard(
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
}
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -788,26 +795,40 @@ private fun DayColumnCard(
) {
val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// the rare column holding two occurrences of one series, which would
// otherwise be two blocks under one key.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
EventBlock(
block = block,
dark = dark,
height = height,
width = laneWidth,
date = date,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
.height(height)
.padding(horizontal = 1.dp),
)
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val laneWidth = colWidth / block.laneCount
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
val place = animatedBlockPlacement(
x = laneWidth * block.lane,
y = top,
width = laneWidth,
height = height,
)
EventBlock(
block = block,
dark = dark,
height = place.height,
width = place.width,
date = date,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y)
.width(place.width)
.height(place.height)
.padding(horizontal = 1.dp),
)
}
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
@@ -882,11 +903,13 @@ private fun EventBlock(
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId
val lifted = draggable && dragController.ghosts(block, date)
val ghost = ghostAlpha(lifted)
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 (ghost < 1f) 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;
@@ -909,11 +932,8 @@ private fun EventBlock(
)
}
if (showTime) {
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
BlockTimeLabel(
label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}