From bf2e59a14e5e50e240c576aa9e8bca8723f09117 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 16:21:25 +0200 Subject: [PATCH] Update the selection bar's times live while moving or resizing (#141) (#403) Feedback from #141: when resizing with the left hand, the finger covers both the event's own time and the hour-column badge. While a selected event is moved or resized, the time under its title in the top bar now follows the gesture. It shows what letting go would save, using the same calculation as the drop (real duration for a move, the untouched edge kept for a resize, DST-safe), and stays on the saved times until the event leaves its starting slot. It only recalculates when the snapped slot changes. Closes #141 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/403 --- .../calendula/ui/common/CalendarChrome.kt | 5 +++++ .../calendula/ui/common/SelectionTopBar.kt | 9 +++++++-- .../calendula/ui/common/TimelineDrag.kt | 20 +++++++++++++++++++ .../calendula/ui/week/WeekScreen.kt | 1 + 4 files changed, 33 insertions(+), 2 deletions(-) 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 d252ad27..56a3a1ab 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 @@ -77,6 +77,8 @@ class EventSelection( val event: EventInstance, val dismiss: () -> Unit, val create: (() -> Unit)? = null, + /** The event at the times an ongoing move or resize would give it, else null. */ + val preview: () -> EventInstance? = { null }, ) /** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */ @@ -126,10 +128,12 @@ fun PublishSelection( compact: Boolean, onDismiss: () -> Unit, onCreate: (() -> Unit)? = null, + preview: () -> EventInstance? = { null }, ) { val slot = LocalChromeSlot.current ?: return val currentDismiss by rememberUpdatedState(onDismiss) val currentCreate by rememberUpdatedState(onCreate) + val currentPreview by rememberUpdatedState(preview) val drafted = onCreate != null val selection = remember(event, drafted) { event?.let { @@ -137,6 +141,7 @@ fun PublishSelection( event = it, dismiss = { currentDismiss() }, create = if (drafted) ({ currentCreate?.invoke() }) else null, + preview = { currentPreview() }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt index 37ef7592..d9d69b71 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SelectionTopBar.kt @@ -11,6 +11,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -30,10 +32,13 @@ import kotlin.time.toJavaInstant @Composable fun SelectionTopBar(selection: EventSelection) { val event = selection.event + // Follows a move or resize, so the times stay readable under the finger. + val preview by remember(selection) { derivedStateOf { selection.preview() } } + val shown = preview ?: event val use24Hour = LocalUse24HourFormat.current val locale = currentLocale() - val whenText = remember(event.start, event.end, use24Hour, locale) { - formatSelectionWhen(event, use24Hour, locale) + val whenText = remember(shown.start, shown.end, use24Hour, locale) { + formatSelectionWhen(shown, use24Hour, locale) } TopAppBar( navigationIcon = { 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 045d14fa..45bb3b4e 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 @@ -19,6 +19,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableIntStateOf @@ -815,6 +816,25 @@ class TimelineDragController { originSlot = drag?.slot } + /** The slot the gesture would land on, changing once per snap rather than per frame. */ + private val landingSlot by derivedStateOf { drag?.slot } + + /** + * The dragged event at the times letting go now would give it, worked out as + * [finish] and [finishResize] do. Null while it is still on its own slot (#141). + */ + fun preview(zone: TimeZone): EventInstance? { + val (date, startMin, spanMin) = landingSlot?.takeIf { it != originSlot } ?: return null + val event = source?.event ?: return null + val edge = resizeEdge + if (edge != null) { + val drop = resizedDrop(event, edge, date, startMin, spanMin, zone) + return event.copy(start = drop.start, end = drop.end) + } + val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone) + return event.copy(start = start, end = start + dragDuration) + } + /** * End a resize, handing back the event's new span — null when it never * resolved or came back to the length it started at. Settles as [finish] does. 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 03873c14..66fe65d5 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 @@ -614,6 +614,7 @@ private fun WeekContent( compact = dragController.selectionCovered, onDismiss = dragController::deselect, onCreate = selection?.event?.takeIf { it.isDraft }?.let { draft -> { createDraft(draft) } }, + preview = { dragController.preview(zone) }, ) BackHandler(enabled = selection != null) { dragController.deselect() } LaunchedEffect(pagerState, dragController) {