diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 5217f9c..6e08202 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -588,7 +588,7 @@ fun CalendarHost( EventMoveHost( reschedule, modifier = Modifier.fillMaxSize(), - raised = chromeHost.selection != null, + raised = chromeHost.selectionBarShown, ) // The bar a long-pressed event brings up in place of the FAB (#141). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt index 77b8184..74fc45a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt @@ -78,6 +78,13 @@ class ChromeHost { var selection by mutableStateOf(null) internal set + /** Whether the selected event is being dragged or resized right now. */ + var selectionBusy by mutableStateOf(false) + internal set + + /** Whether the selection's action bar is showing — it steps aside mid-gesture. */ + val selectionBarShown: Boolean get() = selection != null && !selectionBusy + var actions: ScreenActions? = null private set @@ -101,7 +108,7 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) { /** Hands the screen's selected [event] to the host, for as long as it stays selected. */ @Composable -fun PublishSelection(event: EventInstance?, onDismiss: () -> Unit) { +fun PublishSelection(event: EventInstance?, busy: Boolean, onDismiss: () -> Unit) { val slot = LocalChromeSlot.current ?: return val currentDismiss by rememberUpdatedState(onDismiss) val selection = remember(event) { event?.let { EventSelection(it) { currentDismiss() } } } @@ -111,6 +118,7 @@ fun PublishSelection(event: EventInstance?, onDismiss: () -> Unit) { if (current) host.selection = selection onDispose { if (host.selection === selection) host.selection = null } } + if (current) SideEffect { host.selectionBusy = busy && selection != null } } /** The top bar every calendar screen shares. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt index 0f88cf5..c879eda 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt @@ -64,7 +64,9 @@ fun EventSelectionHost( Box(modifier = modifier.fillMaxSize()) { AnimatedVisibility( - visible = host.selection != null, + // Out of the way while the event is dragged or resized, so the + // bottom of the timeline is there to drop on. + visible = host.selectionBarShown, enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(), exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(), modifier = Modifier @@ -127,7 +129,7 @@ fun EventSelectionHost( .navigationBarsPadding() .padding( start = SnackChipMargin, - bottom = SnackChipMargin + if (host.selection != null) SelectionBarClearance else 0.dp, + bottom = SnackChipMargin + if (host.selectionBarShown) SelectionBarClearance else 0.dp, ) .height(SnackChipHeight), contentAlignment = Alignment.CenterStart, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt index 67c944c..80b5fa6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -67,7 +68,7 @@ fun HourGutter( val locale = currentLocale() // Derived: the drag is rewritten every frame, its snapped start once a slot. val dragStartMin by remember(dragController) { - derivedStateOf { dragController.drag?.startMin } + derivedStateOf { dragController.drag?.markerMin } } val hourAlpha by animateFloatAsState( targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f, @@ -106,6 +107,7 @@ fun HourGutter( } } } + Spacer(Modifier.height(dragController.bottomRoom())) } dragStartMin?.let { startMin -> val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp) 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 fc28f03..b8772d7 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 @@ -5,6 +5,7 @@ import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.ScrollState import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box @@ -39,6 +40,7 @@ import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R @@ -99,6 +101,11 @@ data class TimelineDrag( * drop lands, so it is dropped once the gesture ends ([finish]). */ val edgeOnly: Boolean = false, + /** + * The minute of its day the gutter badge names — the top edge, or for a + * bottom-edge resize the end being dragged (#141). + */ + val markerMin: Int = startMin, ) { /** * What the snap haptics key off: one tick per changed slot, not per frame. @@ -267,11 +274,29 @@ enum class ResizeEdge { Top, Bottom } data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant) /** - * The span after dragging [edge] to [edgeMin], all in minutes from one midnight. + * The span after dragging [edge] to [edgeMin] in day [edgeDay], all in minutes + * from the start day's midnight. * The edge snaps to the grid, and never comes closer than one snap step to the * other end — an event can't be resized away to nothing, or turned inside out. */ -internal fun resizedSpan(edge: ResizeEdge, startMin: Int, endMin: Int, edgeMin: Float): Pair { +internal fun resizedSpan( + edge: ResizeEdge, + startMin: Int, + endMin: Int, + edgeMin: Float, + edgeDay: Int? = null, +): Pair { + // Dragged into a day past the other end — the start into a day after the + // event ends, the end into one before it starts: no change, rather than an + // event collapsed against its other end. + if (edgeDay != null) { + val endDay = (endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0) + val wrongWay = when (edge) { + ResizeEdge.Top -> edgeDay > endDay + ResizeEdge.Bottom -> edgeDay < startMin.floorDiv(MINUTES_PER_DAY) + } + if (wrongWay) return startMin to endMin + } val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES return when (edge) { ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin @@ -489,6 +514,9 @@ class TimelineDragController { /** The slot the resized edge sits on, held against a finger trembling between two. */ private var resizeSlot: Int? = null + /** The day, counted from the event's start day, the finger is resizing in. */ + private var resizeEdgeDay = 0 + fun begin( block: TimedBlock, clipOffsetMin: Int, @@ -670,6 +698,7 @@ class TimelineDragController { resizeColumn = column val dayIndex = if (geometry.isRtl) days.lastIndex - column else column val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt() + resizeEdgeDay = dayOffset return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f } @@ -679,7 +708,7 @@ class TimelineDragController { val raw = pointerResizeMinutes() ?: return val slot = stickySnap(raw - resizeGrabMin, resizeSlot) resizeSlot = slot - val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat()) + val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay) val days = geometry.days val hourPx = geometry.hourPx val columnPx = geometry.columnWidthPx @@ -710,9 +739,32 @@ class TimelineDragController { ) }, edgeOnly = slices.isEmpty(), + markerMin = if (edge == ResizeEdge.Top) { + start.mod(MINUTES_PER_DAY) + } else { + end - (end - 1).floorDiv(MINUTES_PER_DAY) * MINUTES_PER_DAY + }, ) } + /** + * Scroll the timeline so a just-selected block, [extraPx] of handle included, + * clears the action bar [clearancePx] tall over the bottom of the viewport — + * without pushing its top out of sight. + */ + suspend fun revealAboveBar(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) { + val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return + val scroll = geometry.scroll ?: return + if (!block.isAttached) return + val port = viewport.boundsInRoot() + val top = block.positionInRoot().y + val bottom = top + block.size.height + extraPx + val needed = bottom - (port.bottom - clearancePx) + val spare = top - port.top + val by = minOf(needed, spare) + if (by > 0f) scroll.animateScrollBy(by) + } + /** * Stop ghosting the source — the grid draws the drop itself by now — while * the copy is still dissolving. Un-ghosting at [release] instead would leave @@ -1139,6 +1191,13 @@ private fun DragCopy( } } +/** + * Extra scroll room under the hours while an event is selected, so the last of + * the day can scroll clear of the action bar (#141). The columns and the gutter + * share one scroll state, so both have to add exactly this. + */ +fun TimelineDragController.bottomRoom(): Dp = if (selection != null) SelectionBarClearance else 0.dp + /** How far a selected block is lifted, animated between 0 and 1 (#141). */ @Composable fun selectionLift(selected: Boolean): Float { 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 df27411..b100cb7 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 @@ -65,6 +65,9 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.zIndex import androidx.compose.ui.geometry.Offset import de.jeanlucmakiola.calendula.ui.common.PickUp +import de.jeanlucmakiola.calendula.ui.common.SelectionBarClearance +import de.jeanlucmakiola.calendula.ui.common.bottomRoom +import androidx.compose.ui.layout.LayoutCoordinates import de.jeanlucmakiola.calendula.ui.common.ResizeEdge import de.jeanlucmakiola.calendula.ui.common.ResizeHandle import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget @@ -565,7 +568,11 @@ private fun WeekContent( // A long-pressed event stays selected (#141) until something else is touched: // back, another page, a view switch, or any tap. val selection = dragController.selection - PublishSelection(selection?.event, onDismiss = dragController::deselect) + PublishSelection( + event = selection?.event, + busy = dragController.isDragging, + onDismiss = dragController::deselect, + ) BackHandler(enabled = selection != null) { dragController.deselect() } LaunchedEffect(pagerState, dragController) { snapshotFlow { pagerState.currentPage }.drop(1).collect { dragController.deselect() } @@ -1272,6 +1279,9 @@ private fun Timeline( .verticalScroll(scrollState) .onGloballyPositioned { if (active) dragController.geometry.viewport = it }, ) { + // Room past midnight to scroll the day's end clear of the action + // bar, matched by the gutter on the same scroll state (#141). + Spacer(Modifier.height(totalHeight + dragController.bottomRoom())) TimelineColumns( geometry = geometry, gap = COLUMN_GAP, @@ -1582,6 +1592,18 @@ private fun EventBlock( val lifted = draggable && dragController.ghosts(block) val ghost = ghostAlpha(lifted) val raise = selectionLift(selected) + // Scrolled clear of the action bar as it is selected, handle and all. + val blockCoordinates = remember { arrayOfNulls(1) } + val revealExtraPx = with(density) { (ResizeHandleTarget / 2).toPx() } + val barClearancePx = with(density) { SelectionBarClearance.toPx() } + LaunchedEffect(selected) { + if (!selected) return@LaunchedEffect + // A frame for the room under the hours to be laid out first. + withFrameNanos { } + blockCoordinates[0]?.let { + dragController.revealAboveBar(it, revealExtraPx, barClearancePx) + } + } // Not while the block is waiting on its own write: until then it still // shows the times from before it. LaunchedEffect(selected, block.event, lifted) { @@ -1597,7 +1619,8 @@ private fun EventBlock( .zIndex(if (selected) 1f else 0f) // The source stays put as a ghost while its floating copy travels. .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) - .then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier), + .then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier) + .onGloballyPositioned { blockCoordinates[0] = it }, ) { Box( modifier = Modifier diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt index 63ee825..6102d28 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt @@ -82,4 +82,24 @@ class ResizedSpanTest { assertThat(stickySnap(9 * 60 + 12f, held = 9 * 60)).isEqualTo(9 * 60 + 15) assertThat(stickySnap(9 * 60 + 8f, held = null)).isEqualTo(9 * 60 + 15) } + + @Test + fun `an edge dragged into a day past the other end changes nothing`() { + // The start into the day after a same-day event ends. + assertThat(resizedSpan(ResizeEdge.Top, start, end, 24 * 60 + 9 * 60f, edgeDay = 1)) + .isEqualTo(start to end) + // The end into the day before it starts. + assertThat(resizedSpan(ResizeEdge.Bottom, start, end, -60f, edgeDay = -1)) + .isEqualTo(start to end) + } + + @Test + fun `an edge in the event's own days still clamps against the other end`() { + assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f, edgeDay = 0)) + .isEqualTo(end - DRAG_SNAP_MINUTES to end) + // An overnight event's start may move anywhere up to its end's day. + val overnightEnd = 24 * 60 + 2 * 60 + assertThat(resizedSpan(ResizeEdge.Top, start, overnightEnd, 24 * 60 + 60f, edgeDay = 1)) + .isEqualTo(24 * 60 + 60 to overnightEnd) + } }