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:
@@ -17,15 +17,17 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* One all-day strip height for every page, blended between the two pages a swipe
|
||||
* sits between so their timelines stay level mid-swipe. It follows the swipe
|
||||
* directly and only springs when a page's own strip changes at rest. [fallback]
|
||||
* stands in until the pages have reported theirs, so it doesn't open from zero.
|
||||
* One height for every page of something that varies from page to page — the
|
||||
* timelines' all-day strip, the split grid's rows — blended between the two
|
||||
* pages a swipe sits between, so what sits below it moves with the finger rather
|
||||
* 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
|
||||
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 target by remember(pagerState, heights) {
|
||||
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
|
||||
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.SizeTransform
|
||||
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.MaterialTheme
|
||||
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
|
||||
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||
* here is only what's specific to paging the calendar grid: the directional
|
||||
* month/week/day slide and the specs that feed it.
|
||||
* here is only what's specific to paging the calendar grid: the specs its
|
||||
* 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
|
||||
* fixed easing curve.
|
||||
*
|
||||
@@ -59,52 +52,3 @@ fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
|
||||
@Composable
|
||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||
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
|
||||
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
|
||||
* Hoisted drag state for one timeline (#68). It lives above the pager, so a
|
||||
* 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.
|
||||
*/
|
||||
@Stable
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
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.rememberCurrentMinute
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -376,7 +376,7 @@ private fun DayPager(
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
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) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
|
||||
@@ -2,6 +2,19 @@ package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.Animatable
|
||||
@@ -21,7 +34,6 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -70,7 +82,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.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
|
||||
@@ -158,14 +168,12 @@ import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
|
||||
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.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.itemEnter
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -180,7 +188,6 @@ import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
@@ -222,6 +229,27 @@ fun MonthScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
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
|
||||
|
||||
// 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
|
||||
// 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).
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
|
||||
val goNext = {
|
||||
slideDir = 1
|
||||
viewModel.goToNext()
|
||||
// Straight to the pager as well as the anchor: a tap mid-fling must still
|
||||
// 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 jumpToMonth: (YearMonth) -> Unit = { ym ->
|
||||
scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) }
|
||||
}
|
||||
val goPrev = {
|
||||
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 = {
|
||||
val jumpToToday: () -> Unit = {
|
||||
if (scrolling) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(
|
||||
@@ -335,17 +356,12 @@ fun MonthScreen(
|
||||
else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))),
|
||||
)
|
||||
}
|
||||
Unit
|
||||
} 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()
|
||||
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 ->
|
||||
if (scrolling) {
|
||||
scope.launch {
|
||||
@@ -355,19 +371,10 @@ fun MonthScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
|
||||
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(
|
||||
drawerState = drawerState,
|
||||
@@ -462,12 +469,18 @@ fun MonthScreen(
|
||||
SplitMonthContent(
|
||||
state = state,
|
||||
selected = selectedDate,
|
||||
slideDir = slideDir,
|
||||
pagerState = pagerState,
|
||||
weekStart = weekStart,
|
||||
monthState = viewModel::month,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
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,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = { onCreateEvent(it, null) },
|
||||
@@ -475,10 +488,10 @@ fun MonthScreen(
|
||||
} else {
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
pagerState = pagerState,
|
||||
weekStart = weekStart,
|
||||
monthState = viewModel::month,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
@@ -595,41 +608,110 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
@Composable
|
||||
private fun MonthContent(
|
||||
state: MonthUiState,
|
||||
slideDir: Int,
|
||||
pagerState: PagerState,
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
showWeekNumbers: Boolean,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev)
|
||||
|
||||
// Loading and failure concern the provider as a whole, so they stand in for
|
||||
// the pager; a page still loading its own month handles that itself.
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is MonthUiState.Success -> "success-${s.month}"
|
||||
is MonthUiState.Failure -> "failure-${s.reason}"
|
||||
MonthUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "month-transition",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentKey = { it::class },
|
||||
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
|
||||
label = "month-state",
|
||||
) { s ->
|
||||
when (s) {
|
||||
MonthUiState.Loading -> MonthGridLoading()
|
||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is MonthUiState.Success -> MonthGrid(
|
||||
state = s,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
is MonthUiState.Success -> MonthPager(
|
||||
pagerState = pagerState,
|
||||
weekStart = weekStart,
|
||||
monthState = monthState,
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* The split style's expand gesture: a downward drag trades the pane away for the
|
||||
* full grid, an upward one brings it back (#53). Vertical only — the months page
|
||||
* on the pager inside it, and each detector only claims its own axis at the
|
||||
* slop, so a drag can page or expand but never both. Commits the moment the drag
|
||||
* clears [MONTH_EXPAND_THRESHOLD], once per gesture.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberMonthSwipeModifier(
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onExpand: (() -> Unit)? = null,
|
||||
onCollapse: (() -> Unit)? = null,
|
||||
): Modifier {
|
||||
val density = LocalDensity.current
|
||||
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
|
||||
var axis = DragAxis.Undecided
|
||||
private fun rememberSplitExpandDrag(onExpand: () -> Unit, onCollapse: () -> Unit): Modifier {
|
||||
val threshold = with(LocalDensity.current) { MONTH_EXPAND_THRESHOLD.toPx() }
|
||||
val currentExpand by rememberUpdatedState(onExpand)
|
||||
val currentCollapse by rememberUpdatedState(onCollapse)
|
||||
return Modifier.pointerInput(threshold) {
|
||||
var accum = 0f
|
||||
// One commit per gesture: without this a long drag would keep re-firing
|
||||
// every time the accumulator crossed the threshold again.
|
||||
var fired = false
|
||||
detectDragGestures(
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = {
|
||||
accum = Offset.Zero
|
||||
axis = DragAxis.Undecided
|
||||
accum = 0f
|
||||
fired = false
|
||||
},
|
||||
onDragEnd = {
|
||||
accum = Offset.Zero
|
||||
axis = DragAxis.Undecided
|
||||
fired = false
|
||||
},
|
||||
onDragCancel = {
|
||||
accum = Offset.Zero
|
||||
axis = DragAxis.Undecided
|
||||
fired = false
|
||||
},
|
||||
onDrag = { _, drag ->
|
||||
onVerticalDrag = { _, 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) {
|
||||
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
|
||||
val commit = when {
|
||||
accum > threshold -> currentExpand
|
||||
accum < -threshold -> currentCollapse
|
||||
else -> null
|
||||
}
|
||||
if (commit != null) {
|
||||
fired = true
|
||||
@@ -1265,10 +1294,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
|
||||
private fun SplitMonthContent(
|
||||
state: MonthUiState,
|
||||
selected: LocalDate,
|
||||
slideDir: Int,
|
||||
pagerState: PagerState,
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
showWeekNumbers: Boolean,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
@@ -1283,12 +1312,7 @@ private fun SplitMonthContent(
|
||||
// be backed out of. Declared deeper than CalendarHost's view-stack handler,
|
||||
// which is what makes it win while it is enabled.
|
||||
BackHandler(enabled = expanded) { expanded = false }
|
||||
// The swipe wraps the grid rather than living inside it: mid-transition
|
||||
// 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,
|
||||
val expandDrag = rememberSplitExpandDrag(
|
||||
onExpand = { expanded = true },
|
||||
onCollapse = { expanded = false },
|
||||
)
|
||||
@@ -1306,6 +1330,9 @@ private fun SplitMonthContent(
|
||||
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
|
||||
label = "split-expand-transition",
|
||||
) { 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(
|
||||
// Null under reduced motion: nothing is tagged, nothing
|
||||
// travels, and the cross-fade above is the whole transition.
|
||||
@@ -1315,119 +1342,98 @@ private fun SplitMonthContent(
|
||||
MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent)
|
||||
},
|
||||
) {
|
||||
SplitMonthBody(
|
||||
expanded = isExpanded,
|
||||
state = state,
|
||||
selected = selected,
|
||||
slideDir = slideDir,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
swipeModifier = swipeModifier,
|
||||
onSelectDay = onSelectDay,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onSetExpanded = { expanded = it },
|
||||
)
|
||||
val months = SplitMonths(pagerState, weekStart, monthState, static = leaving)
|
||||
if (isExpanded) {
|
||||
SplitMonthExpanded(
|
||||
months = months,
|
||||
selected = selected,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
expandDrag = expandDrag,
|
||||
// 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)
|
||||
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. */
|
||||
@Composable
|
||||
private fun SplitMonthBody(
|
||||
expanded: Boolean,
|
||||
state: MonthUiState.Success,
|
||||
selected: LocalDate,
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
/** What both faces of the split style page through — see [MonthPager]. */
|
||||
private class SplitMonths(
|
||||
val pagerState: PagerState,
|
||||
val weekStart: DayOfWeek,
|
||||
val monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
val static: Boolean,
|
||||
)
|
||||
|
||||
/** The split style at rest: compact grid, handle, then the selected day's events. */
|
||||
@Composable
|
||||
private fun SplitMonthCollapsed(
|
||||
state: MonthUiState.Success,
|
||||
months: SplitMonths,
|
||||
selected: LocalDate,
|
||||
slideDir: Int,
|
||||
showWeekNumbers: Boolean,
|
||||
swipeModifier: Modifier,
|
||||
expandDrag: Modifier,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
onExpand: () -> Unit,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
// The grid stands only as many rows tall as its month spans (#162), so a
|
||||
// swipe between a five-row and a six-row month moves the pane by a row; the
|
||||
// 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()) {
|
||||
// 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
|
||||
// outside: it scrolls, and is full of tappable rows.
|
||||
Column(swipeModifier) {
|
||||
AnimatedContent(
|
||||
// The selection travels *with* the state so each page keeps its
|
||||
// own. Read from outside, both pages would show the incoming
|
||||
// one, and paging visibly threw the marker across the outgoing
|
||||
// grid — onto the new month's 1st, which the old grid still
|
||||
// shows among its trailing days — before the new page arrived.
|
||||
targetState = state to selected,
|
||||
// Keyed on the month alone, so a provider notification refreshing
|
||||
// the month you are on — or a tap moving the selection within it
|
||||
// — updates in place instead of sliding.
|
||||
contentKey = { (s, _) -> s.month },
|
||||
transitionSpec = {
|
||||
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
|
||||
},
|
||||
label = "split-month-transition",
|
||||
) { (s, sel) ->
|
||||
Column(expandDrag) {
|
||||
MonthPager(
|
||||
pagerState = months.pagerState,
|
||||
weekStart = months.weekStart,
|
||||
monthState = months.monthState,
|
||||
static = months.static,
|
||||
modifier = Modifier.fillMaxWidth().height(gridHeight),
|
||||
loading = {},
|
||||
) { index, page ->
|
||||
val height = splitGridHeight(page.weeks.size)
|
||||
DisposableEffect(index, height) {
|
||||
gridHeights[index] = height
|
||||
onDispose { gridHeights.remove(index) }
|
||||
}
|
||||
SplitMonthGrid(
|
||||
state = s,
|
||||
selected = sel,
|
||||
state = page,
|
||||
selected = selected,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSelectDay = onSelectDay,
|
||||
)
|
||||
@@ -1461,38 +1467,31 @@ private fun SplitMonthCollapsed(
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitMonthExpanded(
|
||||
state: MonthUiState.Success,
|
||||
months: SplitMonths,
|
||||
/** Marked nowhere here; it only anchors the outline's morph. */
|
||||
selected: LocalDate,
|
||||
slideDir: Int,
|
||||
showWeekNumbers: Boolean,
|
||||
swipeModifier: Modifier,
|
||||
expandDrag: Modifier,
|
||||
onPickDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> 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
|
||||
// of it, and the handle is the obvious thing to reach for on the way back.
|
||||
Column(Modifier.fillMaxSize().then(swipeModifier)) {
|
||||
AnimatedContent(
|
||||
targetState = state to selected,
|
||||
Column(Modifier.fillMaxSize().then(expandDrag)) {
|
||||
MonthPager(
|
||||
pagerState = months.pagerState,
|
||||
weekStart = months.weekStart,
|
||||
monthState = months.monthState,
|
||||
static = months.static,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentKey = { (s, _) -> s.month },
|
||||
transitionSpec = {
|
||||
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
|
||||
},
|
||||
label = "split-expanded-month-transition",
|
||||
) { (s, sel) ->
|
||||
) { _, page ->
|
||||
MonthGrid(
|
||||
state = s,
|
||||
state = page,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onPickDay,
|
||||
onEventClick = onEventClick,
|
||||
selected = sel,
|
||||
selected = selected,
|
||||
)
|
||||
}
|
||||
SplitExpandHandle(expanded = true, onToggle = onCollapse)
|
||||
@@ -1544,6 +1543,15 @@ private fun SplitExpandHandle(
|
||||
* up goes to the day pane below.
|
||||
*/
|
||||
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
|
||||
// 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
|
||||
@@ -1583,8 +1591,8 @@ internal fun SplitMonthGrid(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = SPLIT_GRID_VERTICAL_PADDING),
|
||||
verticalArrangement = Arrangement.spacedBy(SPLIT_ROW_GAP),
|
||||
) {
|
||||
state.weeks.forEach { week ->
|
||||
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.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||
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.flowOf
|
||||
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.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
@@ -44,6 +48,9 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
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)
|
||||
@HiltViewModel
|
||||
class MonthViewModel @Inject constructor(
|
||||
@@ -87,6 +94,21 @@ class MonthViewModel @Inject constructor(
|
||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.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> =
|
||||
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(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
|
||||
// of provider work for a view that isn't on screen.
|
||||
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
||||
val range = monthGridRange(ym, ws, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(ym, ws, calendars, instances)
|
||||
}
|
||||
month(ym, ws).filterNot { it is MonthUiState.Loading }
|
||||
}
|
||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
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) ----------------------------------
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
|
||||
fun goToPrev() {
|
||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||
realignSelection()
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||
realignSelection()
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||
_selectedDate.value = todayDate
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||
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.drop
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -418,7 +418,7 @@ private fun WeekPager(
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
|
||||
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
||||
WeekFrame(
|
||||
weekStart = weekStartForPage(pagerState.currentPage, firstDay),
|
||||
allDayHeight = allDayHeight,
|
||||
|
||||
Reference in New Issue
Block a user