@@ -1,6 +1,11 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -430,7 +435,10 @@ private fun ContinuousMonthContent(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
ContinuousMonthUiState.Loading -> MonthGridLoading()
|
||||
// The scrolling styles get their own skeleton rather than the paged
|
||||
// grid's: same row height, same header, so nothing reflows when the
|
||||
// months land on top of it.
|
||||
ContinuousMonthUiState.Loading -> ContinuousMonthSkeleton(dense = dense)
|
||||
is ContinuousMonthUiState.Failure ->
|
||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
is ContinuousMonthUiState.Success -> if (dense) {
|
||||
@@ -1037,6 +1045,7 @@ internal fun SplitDayPane(
|
||||
/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
|
||||
@Composable
|
||||
private fun ContinuousWeekPlaceholder() {
|
||||
val pulse = rememberSkeletonPulse()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1048,12 +1057,84 @@ private fun ContinuousWeekPlaceholder() {
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.alpha(pulse)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first-frame skeleton for the scrolling styles: the layout they are about
|
||||
* to become, at the same measurements, so the arriving months replace it in
|
||||
* place instead of shifting everything.
|
||||
*
|
||||
* In practice the window is small enough that this is rarely on screen for long
|
||||
* — it is there for the calendar big enough to make even one month's worth of
|
||||
* recurrence expansion take a moment.
|
||||
*/
|
||||
@Composable
|
||||
private fun ContinuousMonthSkeleton(dense: Boolean) {
|
||||
val pulse = rememberSkeletonPulse()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
if (!dense) {
|
||||
// Stand-in for the sticky month header, so the first rows start
|
||||
// where they will once the real one is there.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp, top = 20.dp, bottom = 8.dp)
|
||||
.width(SKELETON_HEADER_WIDTH)
|
||||
.height(SKELETON_HEADER_HEIGHT)
|
||||
.alpha(pulse)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
MaterialTheme.shapes.small,
|
||||
),
|
||||
)
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
// More rows than a viewport holds; the clip takes the overflow.
|
||||
repeat(6) {
|
||||
ContinuousWeekPlaceholder()
|
||||
Spacer(Modifier.height(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SKELETON_HEADER_WIDTH = 128.dp
|
||||
private val SKELETON_HEADER_HEIGHT = 20.dp
|
||||
|
||||
/**
|
||||
* The slow breath that tells a skeleton from an empty grid. Held at full opacity
|
||||
* when the system asks for reduced motion — the placeholders still read as
|
||||
* unfilled without it.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberSkeletonPulse(): Float {
|
||||
if (rememberReduceMotion()) return 1f
|
||||
val transition = rememberInfiniteTransition(label = "skeleton")
|
||||
val alpha by transition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.4f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 900),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "skeleton-alpha",
|
||||
)
|
||||
return alpha
|
||||
}
|
||||
|
||||
/**
|
||||
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
|
||||
* a multi-day event is one connected bar across the columns; single-day timed
|
||||
|
||||
@@ -20,7 +20,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -101,23 +103,45 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = MonthUiState.Loading,
|
||||
)
|
||||
|
||||
// --- Continuous style (#38) -------------------------------------------
|
||||
// --- Continuous + Dense styles (#38) ----------------------------------
|
||||
//
|
||||
// The continuous grid scrolls through every month there is, so it can't load
|
||||
// one month at a time — it loads a sliding window of month indices around
|
||||
// whatever is on screen. The window only moves when the visible range comes
|
||||
// within WINDOW_EDGE months of a loaded edge, so a scroll re-queries
|
||||
// occasionally rather than on every frame.
|
||||
// These scroll through every month there is, so they can't load "a month" —
|
||||
// they load a sliding window of month indices around whatever is on screen.
|
||||
// The window only moves when the visible range comes within WINDOW_EDGE
|
||||
// months of a loaded edge, so a scroll re-queries occasionally rather than
|
||||
// on every frame.
|
||||
//
|
||||
// The window also *starts small and grows*. A provider Instances query
|
||||
// expands recurrences across its whole range, so opening straight onto a
|
||||
// year of months made the first frame wait for eleven months of expansion
|
||||
// when only one was about to be looked at. It now opens on INITIAL_PAD
|
||||
// months, and each completed load widens by GROWTH_STEP in both directions
|
||||
// until MAX_PAD — the months you can reach by scrolling arrive while you're
|
||||
// still looking at the first one.
|
||||
|
||||
/** How far around the visible range we currently load; grows as loads land. */
|
||||
@Volatile
|
||||
private var loadPad = INITIAL_PAD
|
||||
|
||||
/** Last reported visible range, so a widening step can re-centre on it. */
|
||||
@Volatile
|
||||
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||
.let { it..it }
|
||||
|
||||
// Seeded around today so the first frame has data.
|
||||
private val _loadedMonths = MutableStateFlow(
|
||||
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||
.let { it - WINDOW_PAD..it + WINDOW_PAD },
|
||||
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
|
||||
)
|
||||
|
||||
val continuousState: StateFlow<ContinuousMonthUiState> =
|
||||
combine(_loadedMonths, weekStart) { window, ws -> window to ws }
|
||||
.flatMapLatest { (window, ws) ->
|
||||
combine(_loadedMonths, weekStart, viewStyle) { window, ws, style ->
|
||||
Triple(window, ws, style)
|
||||
}
|
||||
.flatMapLatest { (window, ws, style) ->
|
||||
// Nothing to load for the paged and split styles — they have
|
||||
// their own single-month flow, and querying a year of months
|
||||
// behind them is pure waste.
|
||||
if (!style.isScrolling) return@flatMapLatest flowOf(ContinuousMonthUiState.Loading)
|
||||
// Widened to whole grid weeks at both ends: a month block still
|
||||
// has to know about an event that starts in the boundary week's
|
||||
// clipped-off days, or a bar running into the block would vanish.
|
||||
@@ -135,6 +159,10 @@ class MonthViewModel @Inject constructor(
|
||||
buildContinuousState(window, ws, calendars, instances)
|
||||
}
|
||||
}
|
||||
// A load landing is the cue to reach further out. Widening from here
|
||||
// rather than on a timer means each step waits for the previous one,
|
||||
// so the ladder can never outrun the provider.
|
||||
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
|
||||
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
@@ -149,10 +177,24 @@ class MonthViewModel @Inject constructor(
|
||||
* close enough to a loaded edge to warrant a wider query.
|
||||
*/
|
||||
fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) {
|
||||
nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex)
|
||||
visibleMonths = firstIndex..lastIndex
|
||||
nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex, loadPad)
|
||||
?.let { _loadedMonths.value = clampMonthWindow(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One rung up the ladder: reach [GROWTH_STEP] further in each direction,
|
||||
* stopping at [MAX_PAD]. A no-op once there, so the provider notifications
|
||||
* that re-emit the same window don't restart it.
|
||||
*/
|
||||
private fun widenLoadedWindow() {
|
||||
if (loadPad >= MAX_PAD) return
|
||||
loadPad = (loadPad + GROWTH_STEP).coerceAtMost(MAX_PAD)
|
||||
_loadedMonths.value = clampMonthWindow(
|
||||
visibleMonths.first - loadPad..visibleMonths.last + loadPad,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildContinuousState(
|
||||
window: IntRange,
|
||||
weekStart: DayOfWeek,
|
||||
@@ -347,14 +389,25 @@ internal fun monthGridRange(
|
||||
}
|
||||
|
||||
/**
|
||||
* How many months the continuous window loads beyond the visible range, and how
|
||||
* close the visible range may drift to a loaded edge before it reloads. The pad
|
||||
* is generous relative to the trigger so a steady scroll crosses the trigger
|
||||
* well before it would run out of laid-out months.
|
||||
* The sliding window's shape.
|
||||
*
|
||||
* [INITIAL_PAD] is what the first frame waits for — one month either side of the
|
||||
* visible one, so opening the view costs about what the paged style costs. Each
|
||||
* completed load then reaches [GROWTH_STEP] further out until [MAX_PAD], filling
|
||||
* in the months a scroll could reach while the first ones are already on screen.
|
||||
*
|
||||
* [WINDOW_EDGE] is how close the visible range may drift to a loaded edge before
|
||||
* it reloads. It is always kept below the current pad — a trigger at or beyond
|
||||
* the pad would re-fire the moment its own reload landed.
|
||||
*/
|
||||
private const val WINDOW_PAD = 4
|
||||
private const val INITIAL_PAD = 1
|
||||
private const val GROWTH_STEP = 2
|
||||
private const val MAX_PAD = 5
|
||||
private const val WINDOW_EDGE = 2
|
||||
|
||||
/** The reload trigger for a given pad, held strictly inside it. */
|
||||
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
|
||||
|
||||
/**
|
||||
* Which day the split style should select when the grid lands on [month]:
|
||||
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
|
||||
@@ -373,11 +426,17 @@ internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||
* Kept pure and separate from the view model so the hysteresis — the reason a
|
||||
* scroll doesn't re-query the provider on every frame — is testable on its own.
|
||||
*/
|
||||
internal fun nextLoadWindow(loaded: IntRange, firstVisible: Int, lastVisible: Int): IntRange? {
|
||||
internal fun nextLoadWindow(
|
||||
loaded: IntRange,
|
||||
firstVisible: Int,
|
||||
lastVisible: Int,
|
||||
pad: Int = MAX_PAD,
|
||||
): IntRange? {
|
||||
val edge = edgeForPad(pad)
|
||||
val comfortablyInside =
|
||||
firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last
|
||||
firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last
|
||||
if (comfortablyInside) return null
|
||||
return (firstVisible - WINDOW_PAD)..(lastVisible + WINDOW_PAD)
|
||||
return (firstVisible - pad)..(lastVisible + pad)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -172,25 +172,53 @@ class ContinuousMonthIndexTest {
|
||||
|
||||
@Test
|
||||
fun `nearing a loaded edge widens the window around the visible range`() {
|
||||
// Within a month of the top edge → reload, padded on both sides.
|
||||
// Within two months of the top edge → reload, padded on both sides.
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2))
|
||||
.isEqualTo(-3..6)
|
||||
.isEqualTo(-4..7)
|
||||
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100))
|
||||
.isEqualTo(94..104)
|
||||
.isEqualTo(93..105)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a jump far outside the window reloads around the destination`() {
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501))
|
||||
.isEqualTo(496..505)
|
||||
.isEqualTo(495..506)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reloaded window always clears the trigger it just crossed`() {
|
||||
// Otherwise every scroll frame would re-trigger a query.
|
||||
val window = nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)!!
|
||||
assertThat(nextLoadWindow(window, firstVisible = 1, lastVisible = 2)).isNull()
|
||||
// Otherwise every scroll frame would re-trigger a query — and with the
|
||||
// window growing from a pad of 1, this has to hold at every rung of the
|
||||
// ladder, not just the widest one.
|
||||
(1..8).forEach { pad ->
|
||||
val loaded = (10 - pad)..(10 + pad)
|
||||
val past = 10 + pad + 1
|
||||
val widened = nextLoadWindow(loaded, past, past, pad)!!
|
||||
assertThat(nextLoadWindow(widened, past, past, pad)).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reload trigger stays inside the pad`() {
|
||||
// A trigger at or beyond the pad would fire again the instant its own
|
||||
// reload landed, and the window would query forever.
|
||||
(1..8).forEach { pad -> assertThat(edgeForPad(pad)).isLessThan(pad) }
|
||||
// A one-month window has no room for hysteresis: reload only on contact.
|
||||
assertThat(edgeForPad(1)).isEqualTo(0)
|
||||
assertThat(edgeForPad(0)).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the smallest window reloads on crossing rather than nearing its edge`() {
|
||||
// The first frame loads one month either side of today, which leaves no
|
||||
// room to reload *before* the edge: at pad 1 the trigger is contact.
|
||||
// Only momentary — the first completed load widens the pad to 3, which
|
||||
// buys the usual head start back.
|
||||
val initial = 10..12
|
||||
assertThat(nextLoadWindow(initial, firstVisible = 12, lastVisible = 12, pad = 1)).isNull()
|
||||
assertThat(nextLoadWindow(initial, firstVisible = 13, lastVisible = 13, pad = 1))
|
||||
.isEqualTo(12..14)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user