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 package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -430,7 +435,10 @@ private fun ContinuousMonthContent(
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
) { ) {
when (state) { 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 -> is ContinuousMonthUiState.Failure ->
CalendarFailure(reason = state.reason, onRetry = onRetry) CalendarFailure(reason = state.reason, onRetry = onRetry)
is ContinuousMonthUiState.Success -> if (dense) { 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. */ /** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
@Composable @Composable
private fun ContinuousWeekPlaceholder() { private fun ContinuousWeekPlaceholder() {
val pulse = rememberSkeletonPulse()
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1048,12 +1057,84 @@ private fun ContinuousWeekPlaceholder() {
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.alpha(pulse)
.background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE), .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 * 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 * 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.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -101,23 +103,45 @@ class MonthViewModel @Inject constructor(
initialValue = MonthUiState.Loading, initialValue = MonthUiState.Loading,
) )
// --- Continuous style (#38) ------------------------------------------- // --- Continuous + Dense styles (#38) ----------------------------------
// //
// The continuous grid scrolls through every month there is, so it can't load // These scroll through every month there is, so they can't load "a month" —
// one month at a time — it loads a sliding window of month indices around // they load a sliding window of month indices around whatever is on screen.
// whatever is on screen. The window only moves when the visible range comes // The window only moves when the visible range comes within WINDOW_EDGE
// within WINDOW_EDGE months of a loaded edge, so a scroll re-queries // months of a loaded edge, so a scroll re-queries occasionally rather than
// occasionally rather than on every frame. // 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( private val _loadedMonths = MutableStateFlow(
monthIndexOf(YearMonth(todayDate.year, todayDate.month)) 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> = val continuousState: StateFlow<ContinuousMonthUiState> =
combine(_loadedMonths, weekStart) { window, ws -> window to ws } combine(_loadedMonths, weekStart, viewStyle) { window, ws, style ->
.flatMapLatest { (window, ws) -> 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 // 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 // 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. // 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) 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)) } .catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io) .flowOn(io)
.stateIn( .stateIn(
@@ -149,10 +177,24 @@ class MonthViewModel @Inject constructor(
* close enough to a loaded edge to warrant a wider query. * close enough to a loaded edge to warrant a wider query.
*/ */
fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) { 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) } ?.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( private fun buildContinuousState(
window: IntRange, window: IntRange,
weekStart: DayOfWeek, weekStart: DayOfWeek,
@@ -347,14 +389,25 @@ internal fun monthGridRange(
} }
/** /**
* How many months the continuous window loads beyond the visible range, and how * The sliding window's shape.
* 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 * [INITIAL_PAD] is what the first frame waits for — one month either side of the
* well before it would run out of laid-out months. * 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 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]: * 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 * [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 * 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. * 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 = val comfortablyInside =
firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last
if (comfortablyInside) return null 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 @Test
fun `nearing a loaded edge widens the window around the visible range`() { 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)) assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2))
.isEqualTo(-3..6) .isEqualTo(-4..7)
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100)) assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100))
.isEqualTo(94..104) .isEqualTo(93..105)
} }
@Test @Test
fun `a jump far outside the window reloads around the destination`() { fun `a jump far outside the window reloads around the destination`() {
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501)) assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501))
.isEqualTo(496..505) .isEqualTo(495..506)
} }
@Test @Test
fun `the reloaded window always clears the trigger it just crossed`() { fun `the reloaded window always clears the trigger it just crossed`() {
// Otherwise every scroll frame would re-trigger a query. // Otherwise every scroll frame would re-trigger a query — and with the
val window = nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)!! // window growing from a pad of 1, this has to hold at every rung of the
assertThat(nextLoadWindow(window, firstVisible = 1, lastVisible = 2)).isNull() // 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 @Test