From c3401ed61c0cbfa56d735b7af8a5a7407a5f3c48 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 14:22:47 +0200 Subject: [PATCH] Add haptics to split-pane swipes and event drops (#116) (#401) - Swiping between days in the split month pane now ticks, like pulling the pane open and closed already did. - Dropping a moved or resized event gives a confirm buzz. It only fires on a real drop; putting an event back where it was, cancelling, or undoing stays silent. - Fixes a #141 flicker: after a resize, the block dimmed briefly as the floating copy faded out. It now takes over at full opacity, as a block released in place already did. Deviation from the issue: the original ask (haptics when swiping the split view up and down) shipped in 2.22.0, so this covers the remaining gaps from the analysis. Month and week paging stays silent on purpose, and settings switches are left for a later floret-kit change. Closes #116 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/401 --- .../calendula/ui/common/EventDrag.kt | 14 ++++++++++++-- .../calendula/ui/common/TimelineDrag.kt | 9 ++++++++- .../jeanlucmakiola/calendula/ui/month/MonthDrag.kt | 6 ++++++ .../calendula/ui/month/MonthScreen.kt | 12 +++++++++--- .../jeanlucmakiola/calendula/ui/week/WeekScreen.kt | 3 ++- 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt index c1746b4..5b786f9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventDrag.kt @@ -212,11 +212,21 @@ private fun rememberPickUpSurface( } } -/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */ +/** + * A lift on pickup, a tick every time the drop target snaps to a new slot, and a + * confirm each time [drops] counts a real drop. + */ @Composable -fun DragSnapHaptics(slot: Any?) { +fun DragSnapHaptics(slot: Any?, drops: Int) { val haptics = LocalHapticFeedback.current val previous = remember { arrayOfNulls(1) } + val dropsSeen = remember { intArrayOf(drops) } + LaunchedEffect(drops) { + if (drops != dropsSeen[0]) { + dropsSeen[0] = drops + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + } + } LaunchedEffect(slot) { val had = previous[0] previous[0] = slot 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 e4377bd..045d14f 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 @@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -467,6 +468,10 @@ class TimelineDragController { var settling: TimelineDrag? by mutableStateOf(null) private set + /** How many real drops this controller has handed back. */ + var drops by mutableIntStateOf(0) + private set + /** * Which block is lifted, and whether anything is. Separate snapshot state * from [drag], which changes every frame, so blocks that only need "am I the @@ -770,6 +775,7 @@ class TimelineDragController { liftedInstanceId = landed.event.instanceId settledOrigin = landed.event.eventId to landed.event.start settledAbandonedMark = abandonedTicks?.value ?: 0 + drops++ return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset) } @@ -826,6 +832,7 @@ class TimelineDragController { settledOrigin = landed.event.eventId to landed.event.start settledAbandonedMark = abandonedTicks?.value ?: 0 settledEnd = drop.end + drops++ return drop } @@ -1160,7 +1167,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = // 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. - DragSnapHaptics(controller.drag?.slot) + DragSnapHaptics(controller.drag?.slot, controller.drops) // Only once the finger has moved: a long press held near an edge must not // scroll the drop target out from under a finger that only means to select. LaunchedEffect(controller.isDragging && controller.pointerMoved) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt index 746dad4..9e7df88 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -133,6 +134,10 @@ class MonthDragController { var settling: MonthChipDrag? by mutableStateOf(null) private set + /** How many real drops this controller has handed back. */ + var drops by mutableIntStateOf(0) + private set + /** * Which chip is lifted, and whether anything is. Separate snapshot state from * [drag], which changes every frame, so the chips and rows that only need @@ -291,6 +296,7 @@ class MonthDragController { settledGhost = true undoable = settling settledAbandonedMark = abandonedTicks?.value ?: 0 + drops++ return MonthChipDrop(landed.event, landed.grabDate, target) } 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 2e778c2..27ec981 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 @@ -596,7 +596,7 @@ private fun MonthDragOverlay(controller: MonthDragController) { 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) + DragSnapHaptics(controller.drag?.targetDate, controller.drops) // Hold the landed chip until the write is done — including the time a // recurring drop's scope dialog is up — and then, unlike the timeline, carry // it to its seat: it was dropped at whatever height the finger was at, while @@ -1463,8 +1463,14 @@ private fun SplitMonthContent( showWeekNumbers = showWeekNumbers, expandDrag = expandDrag, onSelectDay = onSelectDay, - onPaneSwipeNext = onPaneSwipeNext, - onPaneSwipePrev = onPaneSwipePrev, + onPaneSwipeNext = { + onPaneSwipeNext() + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + }, + onPaneSwipePrev = { + onPaneSwipePrev() + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + }, onClearSelection = onClearSelection, onOpenDay = onOpenDay, onEventClick = onEventClick, 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 85d3799..03873c1 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 @@ -587,7 +587,8 @@ private fun WeekContent( dragController.select(resized) return@resize } - dragController.select(resized, moved = true) + // Still lifted under its copy, so it takes over without the ghost's fade-in dip. + dragController.select(resized, moved = true, fromLift = true) move?.move( MoveRequest( eventId = drop.event.eventId,