feat(month): month view style picker + Split pull-to-expand (#38, #53) #90

Merged
makiolaj merged 36 commits from feat/month-view-style into release/v2.16.0 2026-07-20 21:25:50 +00:00
3 changed files with 195 additions and 27 deletions
Showing only changes of commit 95f4d28bbb - Show all commits

View File

@@ -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

View File

@@ -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)
}
/**

View File

@@ -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