diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt new file mode 100644 index 0000000..877b3b6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -0,0 +1,41 @@ +package de.jeanlucmakiola.calendula.ui.common + +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.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.ui.unit.Dp +import de.jeanlucmakiola.floret.identity.rememberReduceMotion + +/** 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 = 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt index 3b67f95..4b3b508 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -1,25 +1,13 @@ 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.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.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.material3.FloatingActionButtonDefaults +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -29,14 +17,12 @@ 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.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 @@ -51,8 +37,6 @@ 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 chip carrying Undo. @@ -113,78 +97,26 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) modifier = Modifier .align(Alignment.BottomStart) .navigationBarsPadding() - .padding(start = 16.dp, bottom = 16.dp) - .height(CHIP_HEIGHT), + .padding(start = SnackChipMargin, bottom = SnackChipMargin) + .height(SnackChipHeight), contentAlignment = Alignment.CenterStart, ) { - MoveChip( + SnackChip( visible = outcome != null, message = shownMessage, maxWidth = chipMaxWidth, - onUndo = shownUndo?.let { undo -> { viewModel.undo(undo) } }, + actionLabel = stringResource(R.string.event_move_undo) + .takeIf { shownUndo != null }, + onAction = shownUndo?.let { undo -> { viewModel.undo(undo) } }, + // The FAB's own colours: the chip shares its band, so sharing its + // fill reads as one bottom layer rather than two competing ones. + color = FloatingActionButtonDefaults.containerColor, + contentColor = contentColorFor(FloatingActionButtonDefaults.containerColor), ) } } } -/** - * 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, - ) - } - } - } - } - } -} - /** * "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events * are read back on the UTC calendar day they are anchored to (#65, #82). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt index 554c4a2..cfae0b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt @@ -62,17 +62,15 @@ fun moveInFlight(): Boolean { 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. + * 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, landed: Boolean): Float = animateFloatAsState( - targetValue = when { - !lifted -> 1f - landed -> 0f - else -> GHOST_ALPHA - }, +fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState( + targetValue = if (lifted) GHOST_ALPHA else 1f, label = "ghost-alpha", ).value diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index bac2b14..c6818f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -1,6 +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 @@ -300,11 +301,19 @@ 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. + * 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, and + * releasing on the write alone would still flash the old slot. */ -const val SETTLE_GRACE_MILLIS: Long = 450L +const val SETTLE_GRACE_MILLIS: Long = 200L + +/** + * 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 @@ -328,10 +337,14 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = 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. + // recurring drop's scope dialog is up — then a grace for the re-read, then + // fade it into the grid's own block, which has slid in underneath by now. + var handingOver by remember(controller.settling) { mutableStateOf(false) } LaunchedEffect(controller.settling, moveInFlight) { if (controller.settling == null || moveInFlight) return@LaunchedEffect delay(SETTLE_GRACE_MILLIS) + handingOver = true + delay(SETTLE_FADE_MILLIS.toLong()) controller.release() } @@ -351,6 +364,11 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = 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)}–" + @@ -374,6 +392,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = scaleX = 1f + 0.02f * lift scaleY = 1f + 0.02f * lift shadowElevation = 8.dp.toPx() * lift + alpha = copyAlpha shape = RoundedCornerShape(4.dp) clip = false } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 0d64220..3fdb258 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -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,6 +80,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.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.MoveRequest @@ -640,25 +642,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() 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) { @@ -714,12 +730,12 @@ private fun EventBlock( onCancel = dragController::cancel, ) val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId - val ghost = ghostAlpha(lifted, landed = dragController.settling != null) + val ghost = ghostAlpha(lifted) Box( modifier = 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) + .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; diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 364aaf4..f782d94 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -2266,7 +2266,7 @@ private fun MonthBar( // 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 ghost = ghostAlpha(lifted) val shape = RoundedCornerShape( topStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp, @@ -2275,7 +2275,7 @@ private fun MonthBar( ) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) - .then(if (lifted) Modifier.alpha(ghost) else Modifier) + .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .background(fill, shape) .padding(horizontal = 4.dp) .semantics { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index c189e3c..e8f0759 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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,6 +89,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.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.MoveRequest @@ -788,26 +790,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() 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) { @@ -883,12 +899,12 @@ private fun EventBlock( onCancel = dragController::cancel, ) val lifted = draggable && dragController.liftedInstanceId == block.event.instanceId - val ghost = ghostAlpha(lifted, landed = dragController.settling != null) + 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 fades out as the copy settles on its new slot. - .then(if (lifted) Modifier.alpha(ghost) else Modifier) + .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; diff --git a/floret-kit b/floret-kit index ed1d3ca..0ce855c 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit ed1d3ca5e8ff0d63e2ed9d7ece25bcfbcd3be02d +Subproject commit 0ce855c925c9e7af64f10b2595bf151f59f37bd5