Page the month view with a pager that follows the finger (#336)

Paged and Split now page like the week and day views; Split keeps its
vertical expand drag, now vertical-only beside the pager, and the pane
follows the grid's row count mid-swipe. The old trigger swipe and slide
transition are gone with their last callers.

Closes #336
This commit is contained in:
2026-09-30 12:44:22 +02:00
parent 9f05a53ed2
commit d7315caa20
8 changed files with 339 additions and 426 deletions
@@ -17,15 +17,17 @@ import kotlinx.coroutines.flow.collectLatest
import kotlin.math.floor import kotlin.math.floor
/** /**
* One all-day strip height for every page, blended between the two pages a swipe * One height for every page of something that varies from page to page — the
* sits between so their timelines stay level mid-swipe. It follows the swipe * timelines' all-day strip, the split grid's rows — blended between the two
* directly and only springs when a page's own strip changes at rest. [fallback] * pages a swipe sits between, so what sits below it moves with the finger rather
* stands in until the pages have reported theirs, so it doesn't open from zero. * than jumping on settle. It follows the swipe directly and only springs when a
* page's own height changes at rest. [fallback] stands in until the pages have
* reported theirs, so it doesn't open from zero.
* *
* @param heights each composed page's own strip height, by page index. * @param heights each composed page's own height, by page index.
*/ */
@Composable @Composable
fun pagedStripHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp { fun pagedHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
val currentFallback by rememberUpdatedState(fallback) val currentFallback by rememberUpdatedState(fallback)
val target by remember(pagerState, heights) { val target by remember(pagerState, heights) {
derivedStateOf { derivedStateOf {
@@ -1,79 +0,0 @@
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()
}
}
},
)
}
}
@@ -1,13 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -19,12 +12,12 @@ import androidx.compose.ui.unit.IntOffset
* the reduce-motion check — now live in floret-kit's identity module * the reduce-motion check — now live in floret-kit's identity module
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`, * (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays * `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
* here is only what's specific to paging the calendar grid: the directional * here is only what's specific to paging the calendar grid: the specs its
* month/week/day slide and the specs that feed it. * pagers settle with, and that the view switch reuses.
*/ */
/** /**
* The M3 Expressive spatial spring used for the month/week/day slide: the * The M3 Expressive spatial spring the calendar's motion is built on: the
* *default* spring-physics spec from the active motion scheme, rather than a * *default* spring-physics spec from the active motion scheme, rather than a
* fixed easing curve. * fixed easing curve.
* *
@@ -59,52 +52,3 @@ fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
@Composable @Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> = fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultEffectsSpec() MaterialTheme.motionScheme.defaultEffectsSpec()
/**
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
* outgoing page slides and fades one way while the incoming one arrives from the
* other, position on a spring and opacity on an easing curve.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the opacity half, and for the whole
* transition under reduced motion; typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, drop the movement and cross-fade alone.
*/
fun calendarSlideTransition(
slideDir: Int,
spec: FiniteAnimationSpec<IntOffset>,
fadeSpec: FiniteAnimationSpec<Float>,
reduceMotion: Boolean,
): ContentTransform {
if (reduceMotion) {
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
}
val dir = if (slideDir == 0) 1 else slideDir
return ContentTransform(
targetContentEnter =
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. Left off even
// where the two pages differ in height — the split grid stands as many
// rows as its month spans (#162) — since a page sliding out over the row
// below it reads as travel, and the shear reads as a fault.
sizeTransform = SizeTransform(clip = false),
)
}
/**
* How far a page travels, as a fraction of the container width.
*
* A full width was the obvious reading of "paging", but the two pages are
* stacked and both opaque, so a full-width slide showed one grid racing across
* another — the movement carried the whole transition and had a long way to go.
* Under M3's shared-axis pattern the offset only has to *hint* the direction
* while the cross-fade does the swapping, so a fifth of the width is plenty and
* leaves nothing skating past.
*/
private const val SLIDE_TRAVEL_DIVISOR = 5
@@ -284,8 +284,8 @@ class TimelineGeometry {
} }
/** /**
* Hoisted drag state for one timeline (#68). It lives above the per-page * Hoisted drag state for one timeline (#68). It lives above the pager, so a
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the * page change mid-drag can't strand a ghost, and the
* block it renders is drawn in an overlay, clear of the day column's clip. * block it renders is drawn in an overlay, clear of the day column's clip.
*/ */
@Stable @Stable
@@ -12,7 +12,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -376,7 +376,7 @@ private fun DayPager(
onDrop: (TimelineDrop) -> Unit, onDrop: (TimelineDrop) -> Unit,
) { ) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() } val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight) val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) { DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
@@ -2,6 +2,19 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterExitState
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
@@ -21,7 +34,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -70,7 +82,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -144,7 +155,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure 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.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
@@ -158,14 +168,12 @@ import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill 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.rememberCalendarFadeSpec
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.itemEnter import de.jeanlucmakiola.floret.identity.itemEnter
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -180,7 +188,6 @@ import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.math.abs
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
@@ -222,6 +229,27 @@ fun MonthScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scrolling = viewStyle.isScrolling val scrolling = viewStyle.isScrolling
// The paged and split styles' months. Made afresh whenever a paged style
// comes on screen, opening on the month the scrolling style was showing: a
// pager that was never laid out can't be scrolled to follow it meanwhile.
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
val pagerState = key(scrolling) {
rememberPagerState(initialPage = anchorPage) { continuousMonthCount() }
}
val pageSpec = rememberCalendarPageSpec()
val reduceMotion = rememberReduceMotion()
// A swipe moves the pager and the anchor follows once it settles; anything
// else that moves the anchor has the pager follow it instead.
LaunchedEffect(anchorPage, scrolling) {
if (scrolling || pagerState.currentPage == anchorPage || pagerState.isScrollInProgress) {
return@LaunchedEffect
}
pagerState.jumpToPage(anchorPage, reduceMotion, pageSpec)
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
}
val dense = viewStyle == MonthViewStyle.Dense val dense = viewStyle == MonthViewStyle.Dense
// Today, from whichever state is driving; the clock only covers the first // Today, from whichever state is driving; the clock only covers the first
@@ -260,7 +288,8 @@ fun MonthScreen(
} }
} }
} }
val titleMonth = if (scrolling) visibleMonth else month // The month under the finger, so the title turns over mid-swipe, not after it.
val titleMonth = if (scrolling) visibleMonth else yearMonthForIndex(pagerState.currentPage)
// Feed the visible range back so the sliding data window can follow. Both // Feed the visible range back so the sliding data window can follow. Both
// styles report *months*, whatever they index by — one window serves both. // styles report *months*, whatever they index by — one window serves both.
@@ -313,21 +342,13 @@ fun MonthScreen(
} }
} }
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). // Straight to the pager as well as the anchor: a tap mid-fling must still
var slideDir by remember { mutableIntStateOf(0) } // land, and the anchor hasn't caught up with the fling yet to tell the
// difference. The continuous stream scrolls to the month instead.
val goNext = { val jumpToMonth: (YearMonth) -> Unit = { ym ->
slideDir = 1 scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) }
viewModel.goToNext()
} }
val goPrev = { val jumpToToday: () -> Unit = {
slideDir = -1
viewModel.goToPrev()
}
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward). The continuous stream
// scrolls to today's week instead — there is nothing to slide.
val jumpToToday = {
if (scrolling) { if (scrolling) {
scope.launch { scope.launch {
listState.animateScrollToItem( listState.animateScrollToItem(
@@ -335,17 +356,12 @@ fun MonthScreen(
else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))),
) )
} }
Unit
} else { } else {
slideDir = when (val s = state) {
is MonthUiState.Success ->
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
else -> 0
}
viewModel.goToToday() viewModel.goToToday()
jumpToMonth(YearMonth(today.year, today.month))
} }
} }
// Drawer jump-to-date: slide from the side the target month lies on. // Drawer jump-to-date.
val jumpToDate: (LocalDate) -> Unit = { target -> val jumpToDate: (LocalDate) -> Unit = { target ->
if (scrolling) { if (scrolling) {
scope.launch { scope.launch {
@@ -355,19 +371,10 @@ fun MonthScreen(
) )
} }
} else { } else {
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
viewModel.goToDate(target) viewModel.goToDate(target)
jumpToMonth(YearMonth(target.year, target.month))
} }
} }
// Selecting a day in the split grid can cross into a neighbour month — a
// tapped leading/trailing day follows to its own month. Point the slide the
// way the month is actually moving, so the incoming page travels the right
// direction instead of reusing whatever the last swipe left in slideDir.
val selectDay: (LocalDate) -> Unit = { date ->
val target = YearMonth(date.year, date.month)
if (target != month) slideDir = if (target < month) -1 else 1
viewModel.selectDate(date)
}
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -462,12 +469,18 @@ fun MonthScreen(
SplitMonthContent( SplitMonthContent(
state = state, state = state,
selected = selectedDate, selected = selectedDate,
slideDir = slideDir, pagerState = pagerState,
weekStart = weekStart,
monthState = viewModel::month,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
onSelectDay = selectDay, // A tapped leading/trailing day follows to its own
// month, pager and all — straight away, or a swipe
// still settling would land and take the tap back.
onSelectDay = { date ->
viewModel.selectDate(date)
jumpToMonth(YearMonth(date.year, date.month))
},
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateEvent = { onCreateEvent(it, null) }, onCreateEvent = { onCreateEvent(it, null) },
@@ -475,10 +488,10 @@ fun MonthScreen(
} else { } else {
MonthContent( MonthContent(
state = state, state = state,
slideDir = slideDir, pagerState = pagerState,
weekStart = weekStart,
monthState = viewModel::month,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -595,41 +608,110 @@ private fun MonthDragOverlay(controller: MonthDragController) {
@Composable @Composable
private fun MonthContent( private fun MonthContent(
state: MonthUiState, state: MonthUiState,
slideDir: Int, pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
) { ) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion() // Loading and failure concern the provider as a whole, so they stand in for
val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev) // the pager; a page still loading its own month handles that itself.
AnimatedContent( AnimatedContent(
targetState = state, targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier), modifier = Modifier.fillMaxSize(),
contentKey = { s -> contentKey = { it::class },
when (s) { transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
is MonthUiState.Success -> "success-${s.month}" label = "month-state",
is MonthUiState.Failure -> "failure-${s.reason}"
MonthUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "month-transition",
) { s -> ) { s ->
when (s) { when (s) {
MonthUiState.Loading -> MonthGridLoading() MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthGrid( is MonthUiState.Success -> MonthPager(
state = s, pagerState = pagerState,
showWeekNumbers = showWeekNumbers, weekStart = weekStart,
onOpenDay = onOpenDay, monthState = monthState,
onEventClick = onEventClick, modifier = Modifier.fillMaxSize(),
) ) { _, page ->
MonthGrid(
state = page,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
}
}
/**
* The paged and split styles' months as pages that follow the finger and snap
* once a swipe passes half a page or is flung (#336). Only the page on screen
* carries the chip drag and the morph tags: its neighbours show some of its
* dates among their leading and trailing days, and would otherwise claim its
* drops and its shared elements.
*
* @param static draw the current page alone, without a pager — for a face of the
* split style on its way out, which must not hold on to the pager state the
* incoming face now drives.
*/
@Composable
private fun MonthPager(
pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
modifier: Modifier = Modifier,
static: Boolean = false,
loading: @Composable () -> Unit = { MonthGridLoading() },
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
) {
if (static) {
Box(modifier) {
MonthPage(pagerState.currentPage, true, weekStart, monthState, loading, page)
}
return
}
val chipDrag = LocalMonthDrag.current
HorizontalPager(
state = pagerState,
modifier = modifier,
// The months either side are composed ahead, so a swipe lands on a loaded
// month rather than watching it fill in.
beyondViewportPageCount = 1,
verticalAlignment = Alignment.Top,
// A held chip moves within its month; the page stays put under it.
userScrollEnabled = chipDrag?.isDragging != true,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { index ->
MonthPage(index, index == pagerState.currentPage, weekStart, monthState, loading, page)
}
}
@Composable
private fun MonthPage(
index: Int,
active: Boolean,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
loading: @Composable () -> Unit,
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
) {
val ym = yearMonthForIndex(index)
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
val chipDrag = LocalMonthDrag.current
val morph = LocalMonthMorph.current
CompositionLocalProvider(
LocalMonthDrag provides chipDrag.takeIf { active },
LocalMonthMorph provides morph.takeIf { active },
) {
when (val s = state) {
is MonthUiState.Success -> page(index, s)
else -> loading()
} }
} }
} }
@@ -1148,88 +1230,35 @@ internal fun DenseMonthGrid(
} }
} }
/** Which way a drag went, decided once per gesture and then held. */
private enum class DragAxis { Undecided, Horizontal, Vertical }
/** /**
* The month grid's drag gesture: horizontal pages the month, vertical expands or * The split style's expand gesture: a downward drag trades the pane away for the
* collapses the split style (#53). The grid doesn't follow the finger, so there is * full grid, an upward one brings it back (#53). Vertical only — the months page
* no distance to rubber-band against — it commits the moment the accumulated drag * on the pager inside it, and each detector only claims its own axis at the
* clears the threshold, once per gesture, exactly as * slop, so a drag can page or expand but never both. Commits the moment the drag
* [rememberCalendarPageSwipe][de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe] * clears [MONTH_EXPAND_THRESHOLD], once per gesture.
* does for the week and day views.
*
* 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 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
* rather than being re-read as a page turn.
*/ */
@Composable @Composable
private fun rememberMonthSwipeModifier( private fun rememberSplitExpandDrag(onExpand: () -> Unit, onCollapse: () -> Unit): Modifier {
onSwipeNext: () -> Unit, val threshold = with(LocalDensity.current) { MONTH_EXPAND_THRESHOLD.toPx() }
onSwipePrev: () -> Unit, val currentExpand by rememberUpdatedState(onExpand)
onExpand: (() -> Unit)? = null, val currentCollapse by rememberUpdatedState(onCollapse)
onCollapse: (() -> Unit)? = null, return Modifier.pointerInput(threshold) {
): Modifier { var accum = 0f
val density = LocalDensity.current // One commit per gesture: without this a long drag would keep re-firing
val pageThreshold = with(density) { CALENDAR_SWIPE_THRESHOLD.toPx() } // every time the accumulator crossed the threshold again.
val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) {
var accum = Offset.Zero
var axis = DragAxis.Undecided
var fired = false var fired = false
detectDragGestures( detectVerticalDragGestures(
onDragStart = { onDragStart = {
accum = Offset.Zero accum = 0f
axis = DragAxis.Undecided
fired = false fired = false
}, },
onDragEnd = { onVerticalDrag = { _, drag ->
accum = Offset.Zero
axis = DragAxis.Undecided
fired = false
},
onDragCancel = {
accum = Offset.Zero
axis = DragAxis.Undecided
fired = false
},
onDrag = { _, drag ->
accum += drag accum += drag
if (axis == DragAxis.Undecided) {
// Ties go horizontal, keeping paging the default reading of an
// ambiguous drag as it was before the vertical axis existed.
axis = if (abs(accum.x) >= abs(accum.y)) {
DragAxis.Horizontal
} else {
DragAxis.Vertical
}
}
// 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) { if (!fired) {
val commit = when (axis) { val commit = when {
DragAxis.Horizontal -> when { accum > threshold -> currentExpand
accum.x < -pageThreshold -> onSwipeNext accum < -threshold -> currentCollapse
accum.x > pageThreshold -> onSwipePrev else -> null
else -> null
}
DragAxis.Vertical -> when {
accum.y > expandThreshold -> onExpand
accum.y < -expandThreshold -> onCollapse
else -> null
}
DragAxis.Undecided -> null
} }
if (commit != null) { if (commit != null) {
fired = true fired = true
@@ -1265,10 +1294,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
private fun SplitMonthContent( private fun SplitMonthContent(
state: MonthUiState, state: MonthUiState,
selected: LocalDate, selected: LocalDate,
slideDir: Int, pagerState: PagerState,
weekStart: DayOfWeek,
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onSelectDay: (LocalDate) -> Unit, onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
@@ -1283,12 +1312,7 @@ private fun SplitMonthContent(
// be backed out of. Declared deeper than CalendarHost's view-stack handler, // be backed out of. Declared deeper than CalendarHost's view-stack handler,
// which is what makes it win while it is enabled. // which is what makes it win while it is enabled.
BackHandler(enabled = expanded) { expanded = false } BackHandler(enabled = expanded) { expanded = false }
// The swipe wraps the grid rather than living inside it: mid-transition val expandDrag = rememberSplitExpandDrag(
// there are two grids, and the gesture belongs to neither. The pane is left
// out of it — it scrolls and is full of tappable rows.
val swipeModifier = rememberMonthSwipeModifier(
onSwipeNext = onSwipeNext,
onSwipePrev = onSwipePrev,
onExpand = { expanded = true }, onExpand = { expanded = true },
onCollapse = { expanded = false }, onCollapse = { expanded = false },
) )
@@ -1306,6 +1330,9 @@ private fun SplitMonthContent(
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
label = "split-expand-transition", label = "split-expand-transition",
) { isExpanded -> ) { isExpanded ->
// Only the incoming face drives the pager; the outgoing one draws
// its month still for the length of the cross-fade.
val leaving = transition.targetState == EnterExitState.PostExit
CompositionLocalProvider( CompositionLocalProvider(
// Null under reduced motion: nothing is tagged, nothing // Null under reduced motion: nothing is tagged, nothing
// travels, and the cross-fade above is the whole transition. // travels, and the cross-fade above is the whole transition.
@@ -1315,119 +1342,98 @@ private fun SplitMonthContent(
MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent) MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent)
}, },
) { ) {
SplitMonthBody( val months = SplitMonths(pagerState, weekStart, monthState, static = leaving)
expanded = isExpanded, if (isExpanded) {
state = state, SplitMonthExpanded(
selected = selected, months = months,
slideDir = slideDir, selected = selected,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier, expandDrag = expandDrag,
onSelectDay = onSelectDay, // A tap in the expanded grid picks the day and drops
onOpenDay = onOpenDay, // back, which gives the expanded month a job — a
onEventClick = onEventClick, // chooser you dip into — rather than a mode you can
onCreateEvent = onCreateEvent, // get stranded in. The collapse then runs with the
onSetExpanded = { expanded = it }, // selection already set, so the pane arrives showing
) // the day you picked.
onPickDay = {
onSelectDay(it)
expanded = false
},
// A tapped chip is asking for that event, not for its
// day, so it opens the detail from here too rather
// than collapsing (#187).
onEventClick = onEventClick,
onCollapse = { expanded = false },
)
} else {
SplitMonthCollapsed(
state = state,
months = months,
selected = selected,
showWeekNumbers = showWeekNumbers,
expandDrag = expandDrag,
onSelectDay = onSelectDay,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onExpand = { expanded = true },
)
}
} }
} }
} }
} }
} }
/** The two faces of the split style, sharing one set of morph tags. */ /** What both faces of the split style page through — see [MonthPager]. */
@Composable private class SplitMonths(
private fun SplitMonthBody( val pagerState: PagerState,
expanded: Boolean, val weekStart: DayOfWeek,
state: MonthUiState.Success, val monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
selected: LocalDate, val static: Boolean,
slideDir: Int, )
showWeekNumbers: Boolean,
swipeModifier: Modifier,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
onSetExpanded: (Boolean) -> Unit,
) {
if (expanded) {
SplitMonthExpanded(
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
// A tap in the expanded grid picks the day and drops back, which
// gives the expanded month a job — a chooser you dip into — rather
// than a mode you can get stranded in. The collapse then runs with
// the selection already set, so the pane arrives showing the day
// you picked.
onPickDay = {
onSelectDay(it)
onSetExpanded(false)
},
// A tapped chip is asking for that event, not for its day, so it
// opens the detail from here too rather than collapsing (#187).
onEventClick = onEventClick,
onCollapse = { onSetExpanded(false) },
)
} else {
SplitMonthCollapsed(
state = state,
selected = selected,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
swipeModifier = swipeModifier,
onSelectDay = onSelectDay,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
onExpand = { onSetExpanded(true) },
)
}
}
/** The split style at rest: compact grid, handle, then the selected day's events. */ /** The split style at rest: compact grid, handle, then the selected day's events. */
@Composable @Composable
private fun SplitMonthCollapsed( private fun SplitMonthCollapsed(
state: MonthUiState.Success, state: MonthUiState.Success,
months: SplitMonths,
selected: LocalDate, selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
swipeModifier: Modifier, expandDrag: Modifier,
onSelectDay: (LocalDate) -> Unit, onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit, onCreateEvent: (LocalDate) -> Unit,
onExpand: () -> Unit, onExpand: () -> Unit,
) { ) {
val slideSpec = rememberCalendarSlideSpec() // The grid stands only as many rows tall as its month spans (#162), so a
val fadeSpec = rememberCalendarFadeSpec() // swipe between a five-row and a six-row month moves the pane by a row; the
val reduceMotion = rememberReduceMotion() // blend has it move with the finger rather than jump on settle.
val gridHeights = remember { mutableStateMapOf<Int, Dp>() }
val gridHeight = pagedHeight(months.pagerState, gridHeights, splitGridHeight(state.weeks.size))
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
// Grid and handle drag as one surface — the handle is what advertises the // Grid and handle drag as one surface — the handle is what advertises the
// gesture, so it has to answer to it as well as to a tap. The pane is // gesture, so it has to answer to it as well as to a tap. The pane is
// outside: it scrolls, and is full of tappable rows. // outside: it scrolls, and is full of tappable rows.
Column(swipeModifier) { Column(expandDrag) {
AnimatedContent( MonthPager(
// The selection travels *with* the state so each page keeps its pagerState = months.pagerState,
// own. Read from outside, both pages would show the incoming weekStart = months.weekStart,
// one, and paging visibly threw the marker across the outgoing monthState = months.monthState,
// grid — onto the new month's 1st, which the old grid still static = months.static,
// shows among its trailing days — before the new page arrived. modifier = Modifier.fillMaxWidth().height(gridHeight),
targetState = state to selected, loading = {},
// Keyed on the month alone, so a provider notification refreshing ) { index, page ->
// the month you are on — or a tap moving the selection within it val height = splitGridHeight(page.weeks.size)
// — updates in place instead of sliding. DisposableEffect(index, height) {
contentKey = { (s, _) -> s.month }, gridHeights[index] = height
transitionSpec = { onDispose { gridHeights.remove(index) }
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }
},
label = "split-month-transition",
) { (s, sel) ->
SplitMonthGrid( SplitMonthGrid(
state = s, state = page,
selected = sel, selected = selected,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay, onSelectDay = onSelectDay,
) )
@@ -1461,38 +1467,31 @@ private fun SplitMonthCollapsed(
*/ */
@Composable @Composable
private fun SplitMonthExpanded( private fun SplitMonthExpanded(
state: MonthUiState.Success, months: SplitMonths,
/** Marked nowhere here; it only anchors the outline's morph. */ /** Marked nowhere here; it only anchors the outline's morph. */
selected: LocalDate, selected: LocalDate,
slideDir: Int,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
swipeModifier: Modifier, expandDrag: Modifier,
onPickDay: (LocalDate) -> Unit, onPickDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCollapse: () -> Unit, onCollapse: () -> Unit,
) { ) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// The whole screen drags here, handle included — there is no pane to keep out // The whole screen drags here, handle included — there is no pane to keep out
// of it, and the handle is the obvious thing to reach for on the way back. // of it, and the handle is the obvious thing to reach for on the way back.
Column(Modifier.fillMaxSize().then(swipeModifier)) { Column(Modifier.fillMaxSize().then(expandDrag)) {
AnimatedContent( MonthPager(
targetState = state to selected, pagerState = months.pagerState,
weekStart = months.weekStart,
monthState = months.monthState,
static = months.static,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentKey = { (s, _) -> s.month }, ) { _, page ->
transitionSpec = {
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
},
label = "split-expanded-month-transition",
) { (s, sel) ->
MonthGrid( MonthGrid(
state = s, state = page,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onPickDay, onOpenDay = onPickDay,
onEventClick = onEventClick, onEventClick = onEventClick,
selected = sel, selected = selected,
) )
} }
SplitExpandHandle(expanded = true, onToggle = onCollapse) SplitExpandHandle(expanded = true, onToggle = onCollapse)
@@ -1544,6 +1543,15 @@ private fun SplitExpandHandle(
* up goes to the day pane below. * up goes to the day pane below.
*/ */
private val SPLIT_ROW_HEIGHT = 46.dp private val SPLIT_ROW_HEIGHT = 46.dp
/** Vertical padding and row gap of [SplitMonthGrid], which [splitGridHeight] pays for. */
private val SPLIT_GRID_VERTICAL_PADDING = 4.dp
private val SPLIT_ROW_GAP = 2.dp
/** How tall [SplitMonthGrid] stands for a month spanning [rows] weeks. */
private fun splitGridHeight(rows: Int): Dp =
SPLIT_ROW_HEIGHT * rows + SPLIT_ROW_GAP * (rows - 1).coerceAtLeast(0) +
SPLIT_GRID_VERTICAL_PADDING * 2
private val SPLIT_DOT_SIZE = 5.dp private val SPLIT_DOT_SIZE = 5.dp
// Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for // Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for
// the paged grid's lanes, so the two caps have to be the same number or a dot // the paged grid's lanes, so the two caps have to be the same number or a dot
@@ -1583,8 +1591,8 @@ internal fun SplitMonthGrid(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = AppBarSpacing.Inset, vertical = SPLIT_GRID_VERTICAL_PADDING),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(SPLIT_ROW_GAP),
) { ) {
state.weeks.forEach { week -> state.weeks.forEach { week ->
Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) { Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) {
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
@@ -25,6 +26,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map
import kotlin.math.abs
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
@@ -44,6 +48,9 @@ import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
private const val MONTH_CACHE_SPAN = 2
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class MonthViewModel @Inject constructor( class MonthViewModel @Inject constructor(
@@ -87,6 +94,21 @@ class MonthViewModel @Inject constructor(
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
val month: StateFlow<YearMonth> = _month val month: StateFlow<YearMonth> = _month
/** The pager page the anchor month sits on, for the paged and split styles. */
val anchorPage: StateFlow<Int> = _month
.map { monthIndexOf(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = monthIndexOf(_month.value),
)
/**
* The anchor month's state. Once loaded it never falls back to
* [MonthUiState.Loading]: moving the anchor keeps the last result until the
* new month arrives, since the pages draw their own months and this feeds
* the failure screen and the split style's day pane.
*/
val state: StateFlow<MonthUiState> = val state: StateFlow<MonthUiState> =
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) } combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
.flatMapLatest { (ym, ws, style) -> .flatMapLatest { (ym, ws, style) ->
@@ -94,22 +116,48 @@ class MonthViewModel @Inject constructor(
// Split draw, so under a scrolling style this query is a month // Split draw, so under a scrolling style this query is a month
// of provider work for a view that isn't on screen. // of provider work for a view that isn't on screen.
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading) if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
val range = monthGridRange(ym, ws, zone) month(ym, ws).filterNot { it is MonthUiState.Loading }
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(ym, ws, calendars, instances)
}
} }
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthUiState.Loading, initialValue = MonthUiState.Loading,
) )
private val months = PageStateCache<Pair<YearMonth, DayOfWeek>, MonthUiState>(
scope = viewModelScope,
initial = MonthUiState.Loading,
// Far enough to cover the pages either side and a swipe back, so paging
// through a year doesn't keep a year of queries around. A week-start
// change lays every month out afresh.
keep = { (cached, cachedWs), (requested, ws) ->
cachedWs == ws && abs(monthIndexOf(cached) - monthIndexOf(requested)) <= MONTH_CACHE_SPAN
},
) { (ym, ws) ->
combine(
repository.calendars(),
repository.instances(monthGridRange(ym, ws, zone)),
) { calendars, instances ->
buildState(ym, ws, calendars, instances)
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
}
/**
* Month [ym] laid out with weeks starting on [ws], shared between its pager
* page and [state]. Main thread only.
*/
fun month(ym: YearMonth, ws: DayOfWeek): StateFlow<MonthUiState> = months.get(ym to ws)
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
if (monthIndexOf(_month.value) == page) return
_month.value = yearMonthForIndex(page)
realignSelection()
}
// --- Continuous + Dense styles (#38) ---------------------------------- // --- Continuous + Dense styles (#38) ----------------------------------
// //
// These scroll through every month there is, so they can't load "a month" — // These scroll through every month there is, so they can't load "a month" —
@@ -271,16 +319,6 @@ class MonthViewModel @Inject constructor(
_selectedDate.value = selectionForMonth(_month.value, todayDate) _selectedDate.value = selectionForMonth(_month.value, todayDate)
} }
fun goToPrev() {
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
realignSelection()
}
fun goToNext() {
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
realignSelection()
}
fun goToToday() { fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month) _month.value = YearMonth(todayDate.year, todayDate.month)
_selectedDate.value = todayDate _selectedDate.value = todayDate
@@ -12,7 +12,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -418,7 +418,7 @@ private fun WeekPager(
onDrop: (TimelineDrop) -> Unit, onDrop: (TimelineDrop) -> Unit,
) { ) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() } val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight) val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
WeekFrame( WeekFrame(
weekStart = weekStartForPage(pagerState.currentPage, firstDay), weekStart = weekStartForPage(pagerState.currentPage, firstDay),
allDayHeight = allDayHeight, allDayHeight = allDayHeight,