diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index cbca6d4..12ecb20 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index db574b5..52edd51 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -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 = - 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) } /** diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index 7abfc13..ef2c56b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -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