diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt new file mode 100644 index 0000000..dc15d03 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt @@ -0,0 +1,79 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +/** + * Drag distance that turns a calendar page, shared by the month, week and day + * views so all three answer a swipe at the same point. + * + * It was 6dp once, which is inside the distance a tap wanders: brushing the grid + * changed the month, and a page that turns on an unintended gesture reads as the + * animation misfiring rather than as the gesture being over-eager. + */ +val CALENDAR_SWIPE_THRESHOLD = 24.dp + +/** + * The whole-page horizontal swipe: one page per gesture, committed **the moment + * the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts. + * + * Waiting for the lift meant the page sat still under a finger that had already + * travelled far enough to ask for it, and the answer only arrived once you let + * go — which reads as the view being slow rather than as a deliberate commit. + * Firing on the threshold is what makes the gesture feel like it is being + * followed. The trade is that a drag can no longer be taken back by dragging the + * other way; in practice, once you have moved 24dp deliberately you meant it, and + * the page you land on is one swipe back. + * + * Deliberately **horizontal-only**. The week and day timelines scroll vertically + * underneath this, and a two-dimensional detector here would claim those drags + * before the inner scroll ever saw them. As it is, a horizontal drag crosses this + * detector's slop while a vertical one is consumed below, and the two coexist. + * (The month view's split style needs a vertical axis as well, so it keeps its + * own axis-locking detector rather than using this.) + */ +@Composable +fun rememberCalendarPageSwipe( + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, +): Modifier { + val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() } + return Modifier.pointerInput(onSwipeNext, onSwipePrev) { + var accum = 0f + // One page per gesture: without this a long drag would keep re-firing + // every time the accumulator crossed the threshold again. + var fired = false + detectHorizontalDragGestures( + onDragStart = { + accum = 0f + fired = false + }, + onDragEnd = { + accum = 0f + fired = false + }, + onDragCancel = { + accum = 0f + fired = false + }, + onHorizontalDrag = { _, drag -> + accum += drag + if (!fired) { + val commit = when { + accum < -threshold -> onSwipeNext + accum > threshold -> onSwipePrev + else -> null + } + if (commit != null) { + fired = true + commit() + } + } + }, + ) + } +} 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 4526677..842486e 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 @@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box @@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -79,6 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -266,8 +265,6 @@ private fun DayContent( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - val threshold = with(density) { 24.dp.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() @@ -296,20 +293,7 @@ private fun DayContent( // Whole-page horizontal swipe, one level above the timeline's vertical // scroll: a horizontal drag crosses this detector's slop, while a vertical // drag is consumed by the inner scroll first — the two gestures coexist. - val swipeModifier = Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - when { - dragAccum < -threshold -> onSwipeNext() - dragAccum > threshold -> onSwipePrev() - } - dragAccum = 0f - }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { _, drag -> dragAccum += drag }, - ) - } + val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) AnimatedContent( targetState = state, 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 a8d1882..c504759 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 @@ -75,8 +75,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -100,6 +104,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure +import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha @@ -556,7 +561,13 @@ private val WEEK_NUMBER_GUTTER = 40.dp private val DAY_NUMBER_GAP = 4.dp private val CELL_TOP_PADDING = 6.dp private val CELL_GAP = 2.dp -private val CELL_SHAPE = RoundedCornerShape(12.dp) +/** Named separately because the split style's selection outline draws its own + * rounded rect and has to match this radius exactly. */ +private val CELL_CORNER = 12.dp +private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER) + +/** Width of the split style's selected-day outline. */ +private val SPLIT_SELECTION_STROKE = 1.5.dp private const val MAX_EVENT_ROWS = 3 /** @@ -793,20 +804,21 @@ private enum class DragAxis { Undecided, Horizontal, Vertical } /** * The month grid's drag gesture: horizontal pages the month, vertical expands or - * collapses the split style (#53). Accumulates and commits past a threshold on - * release — the grid doesn't follow the finger, so there is no distance to - * rubber-band against. + * collapses the split style (#53). The grid doesn't follow the finger, so there is + * no distance to rubber-band against — it commits the moment the accumulated drag + * clears the threshold, once per gesture, exactly as + * [rememberCalendarPageSwipe][de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe] + * does for the week and day views. * - * The axis is **locked on the first movement and held for the whole gesture**, so - * a drag can page or expand but never both. Two independent detectors on one - * surface would each see their own component of a diagonal drag and both fire. + * This is the month's own detector rather than that shared one because it needs + * two axes, and the axis is **locked on the first movement and held for the whole + * gesture** so a drag can page or expand but never both. Two independent + * detectors on one surface would each see their own component of a diagonal drag + * and both fire. * - * The horizontal threshold matches the week and day views'. It used to be 6dp, - * which is inside the distance a tap wanders: brushing the grid changed the - * month, and a page that turns on an unintended gesture reads as the animation - * misfiring rather than as the gesture being over-eager. The vertical one is - * larger — swapping the whole layout out deserves a more deliberate pull than - * stepping to the next month. + * The horizontal threshold is the shared one. The vertical is larger — swapping + * the whole layout out deserves a more deliberate pull than stepping to the next + * month. * * [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical * axis unclaimed: the lock still happens, so a vertical drag there does nothing @@ -820,7 +832,7 @@ private fun rememberMonthSwipeModifier( onCollapse: (() -> Unit)? = null, ): Modifier { val density = LocalDensity.current - val pageThreshold = with(density) { MONTH_SWIPE_THRESHOLD.toPx() } + val pageThreshold = with(density) { CALENDAR_SWIPE_THRESHOLD.toPx() } val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() } return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) { var accum = Offset.Zero @@ -833,13 +845,6 @@ private fun rememberMonthSwipeModifier( fired = false }, onDragEnd = { - // Only paging waits for the lift; see onDrag. - if (!fired && axis == DragAxis.Horizontal) { - when { - accum.x < -pageThreshold -> onSwipeNext() - accum.x > pageThreshold -> onSwipePrev() - } - } accum = Offset.Zero axis = DragAxis.Undecided fired = false @@ -860,21 +865,22 @@ private fun rememberMonthSwipeModifier( DragAxis.Vertical } } - // Expand and collapse fire the instant the drag clears the - // threshold, rather than on release. Waiting for the lift left the - // screen inert under a finger that had already asked for the thing, - // and the answer only arrived once you let go — which read as the - // gesture being slow to take rather than as a deliberate commit. - // - // Paging still waits, because there the pending gesture is - // genuinely undecided: you can drag back the other way and settle - // on a different month. There is nothing between expanded and - // collapsed to change your mind about. - if (!fired && axis == DragAxis.Vertical) { - val commit = when { - accum.y > expandThreshold -> onExpand - accum.y < -expandThreshold -> onCollapse - else -> null + // Both axes commit the instant the drag clears their threshold, + // rather than on release — see [rememberCalendarPageSwipe], which + // does the same for the week and day views. + if (!fired) { + val commit = when (axis) { + DragAxis.Horizontal -> when { + accum.x < -pageThreshold -> onSwipeNext + accum.x > pageThreshold -> onSwipePrev + else -> null + } + DragAxis.Vertical -> when { + accum.y > expandThreshold -> onExpand + accum.y < -expandThreshold -> onCollapse + else -> null + } + DragAxis.Undecided -> null } if (commit != null) { fired = true @@ -886,9 +892,6 @@ private fun rememberMonthSwipeModifier( } } -/** Drag distance that commits a month change, matching the week and day views. */ -private val MONTH_SWIPE_THRESHOLD = 24.dp - /** Drag distance that commits an expand/collapse — deliberately longer than a page. */ private val MONTH_EXPAND_THRESHOLD = 48.dp @@ -1298,13 +1301,22 @@ private fun SplitDayCell( // Each cell fades its own outline, so moving the selection reads as one // mark crossing the grid rather than as an outline blinking out here and // reappearing there. Snapped under reduced motion. + // + // Held as a State and read *inside the draw lambda below*, never unwrapped + // here. Read in composition it made every frame of the fade recompose all + // 42 cells at once, which is what made the outline shiver rather than fade. + // Kept in the draw phase, the animation touches nothing but the pixels. val reduceMotion = rememberReduceMotion() val fadeSpec = rememberCalendarFadeSpec() - val selection by animateFloatAsState( + val selection = animateFloatAsState( targetValue = if (isSelected) 1f else 0f, animationSpec = if (reduceMotion) snap() else fadeSpec, label = "split-day-selection", ) + val outlineColor = MaterialTheme.colorScheme.primary + val density = LocalDensity.current + val outlineStroke = with(density) { SPLIT_SELECTION_STROKE.toPx() } + val outlineRadius = with(density) { CELL_CORNER.toPx() } // Background and content are separate layers, mirroring how the paged row is // built — and so the pill can be tagged for the morph on its own. Tagged as // one piece with its contents inside, the dots would be nested shared @@ -1331,11 +1343,22 @@ private fun SplitDayCell( Box( Modifier .fillMaxSize() - .border( - width = 1.5.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = selection), - shape = CELL_SHAPE, - ), + .drawBehind { + val alpha = selection.value + if (alpha <= 0f) return@drawBehind + // Inset by half the stroke: a stroke straddles the path it + // follows, so drawn on the bounds themselves the outer half + // would fall outside the cell and be clipped to a hairline + // along the edges. + val inset = outlineStroke / 2f + drawRoundRect( + color = outlineColor.copy(alpha = alpha), + topLeft = Offset(inset, inset), + size = Size(size.width - outlineStroke, size.height - outlineStroke), + cornerRadius = CornerRadius(outlineRadius - inset), + style = Stroke(width = outlineStroke), + ) + }, ) Column( modifier = Modifier.fillMaxSize().padding(top = 4.dp), 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 88b9a14..ea5243c 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 @@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -94,6 +92,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat @@ -296,8 +295,6 @@ private fun WeekContent( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - val threshold = with(density) { 24.dp.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() @@ -329,20 +326,7 @@ private fun WeekContent( // vertical scroll: a horizontal drag only crosses *this* detector's slop, // while a vertical drag is consumed by the inner scroll first — so the two // gestures coexist without fighting. - val swipeModifier = Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - when { - dragAccum < -threshold -> onSwipeNext() - dragAccum > threshold -> onSwipePrev() - } - dragAccum = 0f - }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { _, drag -> dragAccum += drag }, - ) - } + val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) AnimatedContent( targetState = state,