feat(month): continuous vertical scrolling (#38)
Adds MonthViewStyle.Continuous: one uninterrupted vertical stream of weeks instead of month pages. The layout unit is the ISO week, not the month. A vertical stack of month grids would still repeat a boundary week at the end of one month and the start of the next, which is precisely the duplication the issue asks to be rid of. Streaming weeks means every date appears exactly once. - Weeks are addressed by an absolute index (epoch 1900, so indices stay non-negative and map 1:1 onto LazyColumn item indices), giving the list one stable, gap-free coordinate space to scroll and key by. - The view model loads a sliding window of weeks around the visible range. nextLoadWindow() holds the hysteresis: the window only moves once the visible range comes within four weeks of a loaded edge, so scrolling re-queries the provider occasionally rather than every frame. Unloaded rows render a same-height skeleton, so nothing jumps when the window catches up. - No dimmed "other month" days — every day in the stream belongs to a month equally. Instead the 1st names its month, which is the only marker needed to tell one month from the next, and the top bar title tracks the month the viewport mostly sits in. - The horizontal swipe detector and the paged AnimatedContent are both off in this style; vertical scrolling owns the gesture. Today and drawer jump-to-date animate the list instead of swapping months. The view opens positioned on the current month. MonthWeekRow now takes inMonth as a predicate rather than a YearMonth, which is what lets the same row serve a stream that has no enclosing month. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -19,6 +20,10 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -36,9 +41,12 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -85,11 +93,14 @@ 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.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
@@ -112,8 +123,10 @@ fun MonthScreen(
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val continuousState by viewModel.continuousState.collectAsStateWithLifecycle()
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
@@ -128,17 +141,52 @@ fun MonthScreen(
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isOnCurrentMonth = when (val s = state) {
|
||||
is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month)
|
||||
else -> true
|
||||
val continuous = viewStyle == MonthViewStyle.Continuous
|
||||
|
||||
// Today, from whichever state is driving; the clock only covers the first
|
||||
// frame, before either has loaded.
|
||||
val today = when {
|
||||
continuous -> (continuousState as? ContinuousMonthUiState.Success)?.today
|
||||
else -> (state as? MonthUiState.Success)?.today
|
||||
} ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
|
||||
// Keyed on the week start: week indices are anchored on it, so a changed
|
||||
// week start would leave the saved scroll offset pointing at a different week.
|
||||
val listState = key(weekStart) {
|
||||
rememberLazyListState(
|
||||
initialFirstVisibleItemIndex =
|
||||
weekIndexOf(LocalDate(month.year, month.month, 1), weekStart),
|
||||
)
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is MonthUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
// The continuous stream has no single "current" month, so the title takes the
|
||||
// one the viewport mostly sits in: the midweek day of the row below the top
|
||||
// edge, which flips over as that month's first full week comes into view.
|
||||
val visibleMonth by remember(weekStart) {
|
||||
derivedStateOf {
|
||||
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
|
||||
.plus(3, DateTimeUnit.DAY)
|
||||
YearMonth(midweek.year, midweek.month)
|
||||
}
|
||||
}
|
||||
val titleMonth = if (continuous) visibleMonth else month
|
||||
|
||||
// Feed the visible range back so the sliding data window can follow. The
|
||||
// view model ignores everything that doesn't near a loaded edge.
|
||||
LaunchedEffect(listState, continuous) {
|
||||
if (!continuous) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
val visible = listState.layoutInfo.visibleItemsInfo
|
||||
(visible.firstOrNull()?.index ?: 0) to (visible.lastOrNull()?.index ?: 0)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { (first, last) -> viewModel.onVisibleWeeksChanged(first, last) }
|
||||
}
|
||||
|
||||
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
|
||||
|
||||
// Drives whether the title carries the year.
|
||||
val currentYear = today.year
|
||||
|
||||
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
@@ -152,8 +200,13 @@ fun MonthScreen(
|
||||
viewModel.goToPrev()
|
||||
}
|
||||
// Slide toward today: viewing the future → today comes in from the left
|
||||
// (back), viewing the past → from the right (forward).
|
||||
// (back), viewing the past → from the right (forward). The continuous stream
|
||||
// scrolls to today's week instead — there is nothing to slide.
|
||||
val jumpToToday = {
|
||||
if (continuous) {
|
||||
scope.launch { listState.animateScrollToItem(weekIndexOf(today, weekStart)) }
|
||||
Unit
|
||||
} else {
|
||||
slideDir = when (val s = state) {
|
||||
is MonthUiState.Success ->
|
||||
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
|
||||
@@ -161,11 +214,20 @@ fun MonthScreen(
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
}
|
||||
// Drawer jump-to-date: slide from the side the target month lies on.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
if (continuous) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(
|
||||
weekIndexOf(LocalDate(target.year, target.month, 1), weekStart),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
|
||||
viewModel.goToDate(target)
|
||||
}
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -174,7 +236,7 @@ fun MonthScreen(
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = LocalDate(month.year, month.month, 1),
|
||||
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -195,7 +257,7 @@ fun MonthScreen(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MonthTopBar(
|
||||
month = month,
|
||||
month = titleMonth,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
@@ -213,11 +275,9 @@ fun MonthScreen(
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
// Anchor on today when its month is shown, else the 1st.
|
||||
val today = Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
onCreateEvent(
|
||||
if (isOnCurrentMonth) today
|
||||
else LocalDate(month.year, month.month, 1),
|
||||
else LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
null,
|
||||
)
|
||||
},
|
||||
@@ -231,6 +291,15 @@ fun MonthScreen(
|
||||
) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
if (continuous) {
|
||||
ContinuousMonthContent(
|
||||
state = continuousState,
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
} else {
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
@@ -244,6 +313,7 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -303,6 +373,32 @@ private fun MonthContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous style content. No swipe detector and no [AnimatedContent]: vertical
|
||||
* scrolling owns the gesture, and the list never swaps wholesale — it keeps
|
||||
* scrolling while the window behind it reloads.
|
||||
*/
|
||||
@Composable
|
||||
private fun ContinuousMonthContent(
|
||||
state: ContinuousMonthUiState,
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
ContinuousMonthUiState.Loading -> MonthGridLoading()
|
||||
is ContinuousMonthUiState.Failure ->
|
||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
is ContinuousMonthUiState.Success -> ContinuousMonthGrid(
|
||||
state = state,
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MonthTopBar(
|
||||
@@ -391,6 +487,14 @@ private val CELL_GAP = 2.dp
|
||||
private val CELL_SHAPE = RoundedCornerShape(12.dp)
|
||||
private const val MAX_EVENT_ROWS = 3
|
||||
|
||||
/**
|
||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||
* however many rows the month has; a scrolling stream has no such bound, so it
|
||||
* fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows —
|
||||
* close to what a five-row month gets on a typical phone.
|
||||
*/
|
||||
private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
||||
|
||||
@Composable
|
||||
private fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
@@ -406,11 +510,12 @@ private fun MonthGrid(
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
val month = state.month
|
||||
state.weeks.forEach { week ->
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = state.today,
|
||||
month = state.month,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = Modifier
|
||||
@@ -421,6 +526,78 @@ private fun MonthGrid(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The continuous grid (#38): one uninterrupted vertical stream of weeks.
|
||||
*
|
||||
* The list is indexed by *absolute week number*, so a row's identity never
|
||||
* changes and there is no page seam to scroll across. Weeks the loaded window
|
||||
* hasn't reached yet render as a skeleton and pull the window along behind them.
|
||||
*
|
||||
* Deliberately not a stack of month grids — that would still repeat a boundary
|
||||
* week at the end of one month and the start of the next, which is exactly the
|
||||
* duplication #38 asks to be rid of.
|
||||
*/
|
||||
@Composable
|
||||
private fun ContinuousMonthGrid(
|
||||
state: ContinuousMonthUiState.Success,
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp),
|
||||
) {
|
||||
items(count = weekCount, key = { it }) { index ->
|
||||
val week = state.weeksByIndex[index]
|
||||
if (week == null) {
|
||||
ContinuousWeekPlaceholder()
|
||||
} else {
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = state.today,
|
||||
// Every day in the stream belongs to a month equally — there
|
||||
// is no "other month" to recede here.
|
||||
inMonth = { true },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
labelMonthOnFirst = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(CONTINUOUS_ROW_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
|
||||
@Composable
|
||||
private fun ContinuousWeekPlaceholder() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(CONTINUOUS_ROW_HEIGHT),
|
||||
) {
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -432,10 +609,11 @@ private fun MonthGrid(
|
||||
private fun MonthWeekRow(
|
||||
week: MonthWeek,
|
||||
today: LocalDate,
|
||||
month: YearMonth,
|
||||
inMonth: (LocalDate) -> Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
labelMonthOnFirst: Boolean = false,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
@@ -466,14 +644,13 @@ private fun MonthWeekRow(
|
||||
// as one continuous event.
|
||||
Row(Modifier.matchParentSize()) {
|
||||
week.days.forEach { d ->
|
||||
val inMonth = d.month == month.month && d.year == month.year
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(
|
||||
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
|
||||
color = if (inMonth(d)) MaterialTheme.colorScheme.surfaceContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = CELL_SHAPE,
|
||||
),
|
||||
@@ -487,7 +664,15 @@ private fun MonthWeekRow(
|
||||
DayNumberCell(
|
||||
date = d,
|
||||
isToday = d == today,
|
||||
inMonth = d.month == month.month && d.year == month.year,
|
||||
inMonth = inMonth(d),
|
||||
// Continuous has no month boundaries to dim across, so
|
||||
// the 1st names its month instead — the only marker
|
||||
// telling one month from the next inside the stream.
|
||||
monthLabel = if (labelMonthOnFirst && d.day == 1) {
|
||||
shortMonthName(d)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
@@ -620,6 +805,7 @@ private fun DayNumberCell(
|
||||
isToday: Boolean,
|
||||
inMonth: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
monthLabel: String? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.height(DAY_NUMBER_HEIGHT),
|
||||
@@ -639,6 +825,16 @@ private fun DayNumberCell(
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
} else if (monthLabel != null) {
|
||||
Text(
|
||||
text = "$monthLabel ${date.day}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Visible,
|
||||
softWrap = false,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
@@ -650,6 +846,16 @@ private fun DayNumberCell(
|
||||
}
|
||||
}
|
||||
|
||||
/** Locale-short month name ("Jul", "Juli"), for the 1st in the continuous grid. */
|
||||
@Composable
|
||||
private fun shortMonthName(date: LocalDate): String {
|
||||
val locale = currentLocale()
|
||||
return remember(date.month, locale) {
|
||||
java.time.Month.of(date.month.ordinal + 1)
|
||||
.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
}
|
||||
|
||||
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
|
||||
@Composable
|
||||
private fun MonthBar(
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.YearMonth
|
||||
|
||||
@@ -37,6 +38,23 @@ data class MonthWeek(
|
||||
val countByDay: Map<LocalDate, Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* State for the continuous style (#38). Weeks are keyed by absolute week index
|
||||
* rather than gathered into months: the whole point of the style is that there
|
||||
* are no month boundaries to scroll across, so there is no "current month" here
|
||||
* and no notion of an out-of-month day. [weeksByIndex] holds only the loaded
|
||||
* window; indices outside it render as placeholders until the window catches up.
|
||||
*/
|
||||
sealed interface ContinuousMonthUiState {
|
||||
data object Loading : ContinuousMonthUiState
|
||||
data class Failure(val reason: FailureReason) : ContinuousMonthUiState
|
||||
data class Success(
|
||||
val today: LocalDate,
|
||||
val weeksByIndex: Map<Int, MonthWeek>,
|
||||
val weekStart: DayOfWeek,
|
||||
) : ContinuousMonthUiState
|
||||
}
|
||||
|
||||
sealed interface MonthUiState {
|
||||
data object Loading : MonthUiState
|
||||
data class Failure(val reason: FailureReason) : MonthUiState
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
@@ -66,6 +67,14 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** How the grid is laid out and navigated (#38, #53). */
|
||||
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = MonthViewStyle.Paged,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
@@ -91,6 +100,73 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = MonthUiState.Loading,
|
||||
)
|
||||
|
||||
// --- Continuous style (#38) -------------------------------------------
|
||||
//
|
||||
// The continuous grid is one endless stream of weeks, so it can't load "a
|
||||
// month" — it loads a sliding window of week indices around whatever is on
|
||||
// screen. The window only moves when the visible range comes within
|
||||
// WINDOW_EDGE weeks of a loaded edge, so a scroll re-queries occasionally
|
||||
// rather than on every frame.
|
||||
|
||||
// Seeded around today so the first frame has data. The week-start preference
|
||||
// hasn't arrived yet, but anchoring on a different day shifts an index by at
|
||||
// most one week — well inside the pad, and the first scroll report corrects it.
|
||||
private val _loadedWeeks = MutableStateFlow(
|
||||
weekIndexOf(todayDate, DayOfWeek.MONDAY).let { it - WINDOW_PAD..it + WINDOW_PAD },
|
||||
)
|
||||
|
||||
val continuousState: StateFlow<ContinuousMonthUiState> =
|
||||
combine(_loadedWeeks, weekStart) { window, ws -> window to ws }
|
||||
.flatMapLatest { (window, ws) ->
|
||||
val first = weekStartForIndex(window.first, ws)
|
||||
val last = weekStartForIndex(window.last, ws).plus(6, DateTimeUnit.DAY)
|
||||
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildContinuousState(window, ws, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = ContinuousMonthUiState.Loading,
|
||||
)
|
||||
|
||||
/**
|
||||
* Report which absolute week indices are on screen. Cheap to call on every
|
||||
* scroll frame: it returns immediately unless the visible range has drifted
|
||||
* close enough to a loaded edge to warrant a wider query.
|
||||
*/
|
||||
fun onVisibleWeeksChanged(firstIndex: Int, lastIndex: Int) {
|
||||
nextLoadWindow(_loadedWeeks.value, firstIndex, lastIndex)?.let { _loadedWeeks.value = it }
|
||||
}
|
||||
|
||||
private fun buildContinuousState(
|
||||
window: IntRange,
|
||||
weekStart: DayOfWeek,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): ContinuousMonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val weeks = window.associateWith { index ->
|
||||
val days = (0 until 7).map {
|
||||
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||
}
|
||||
layoutCalendarWeek(days, instances, zone)
|
||||
}
|
||||
return ContinuousMonthUiState.Success(
|
||||
today = todayDate,
|
||||
weeksByIndex = weeks,
|
||||
weekStart = weekStart,
|
||||
)
|
||||
}
|
||||
|
||||
fun goToPrev() {
|
||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||
}
|
||||
@@ -225,6 +301,52 @@ internal fun monthGridRange(
|
||||
return start..end
|
||||
}
|
||||
|
||||
/**
|
||||
* How many weeks 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 weeks.
|
||||
*/
|
||||
private const val WINDOW_PAD = 12
|
||||
private const val WINDOW_EDGE = 4
|
||||
|
||||
/**
|
||||
* The window to load for a visible range, or null to keep the current one.
|
||||
*
|
||||
* 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? {
|
||||
val comfortablyInside =
|
||||
firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last
|
||||
if (comfortablyInside) return null
|
||||
return (firstVisible - WINDOW_PAD)..(lastVisible + WINDOW_PAD)
|
||||
}
|
||||
|
||||
/**
|
||||
* The continuous grid addresses weeks by an absolute index rather than a
|
||||
* (month, row) pair, so the list has one stable, gap-free coordinate space to
|
||||
* scroll through and key its items by. Index 0 is the first week of 1900 under
|
||||
* the active week-start; the list runs to [CONTINUOUS_WEEK_COUNT].
|
||||
*
|
||||
* The epoch is deliberately far in the past so every index is non-negative,
|
||||
* which keeps the LazyColumn's item indices and week indices the same number.
|
||||
*/
|
||||
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
||||
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||
|
||||
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
|
||||
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
|
||||
}
|
||||
|
||||
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
||||
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
||||
|
||||
/** Total weeks the continuous grid scrolls through (1900 → 2100). */
|
||||
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||
|
||||
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The continuous grid's coordinate space. Every row's identity — and the
|
||||
* LazyColumn item it maps to — hangs off this arithmetic, so it gets its own
|
||||
* tests rather than being exercised only through the UI.
|
||||
*/
|
||||
class ContinuousWeekIndexTest {
|
||||
|
||||
// 2026-06-08 is a Monday; 2026-06-10 the Wednesday of the same week.
|
||||
private val mon = LocalDate(2026, 6, 8)
|
||||
private val wed = LocalDate(2026, 6, 10)
|
||||
|
||||
@Test
|
||||
fun `every day of a week shares one index`() {
|
||||
val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
|
||||
assertThat(indices.toSet()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consecutive weeks are consecutive indices`() {
|
||||
val a = weekIndexOf(mon, DayOfWeek.MONDAY)
|
||||
val b = weekIndexOf(mon.plus(7, DateTimeUnit.DAY), DayOfWeek.MONDAY)
|
||||
val back = weekIndexOf(mon.plus(-7, DateTimeUnit.DAY), DayOfWeek.MONDAY)
|
||||
assertThat(b).isEqualTo(a + 1)
|
||||
assertThat(back).isEqualTo(a - 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `index round-trips back to the week's first day`() {
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
val index = weekIndexOf(wed, ws)
|
||||
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the week start shifts which week a boundary day belongs to`() {
|
||||
// Sunday the 14th closes the Monday-anchored week but opens the Sunday one.
|
||||
val sun = LocalDate(2026, 6, 14)
|
||||
assertThat(weekIndexOf(sun, DayOfWeek.MONDAY))
|
||||
.isEqualTo(weekIndexOf(mon, DayOfWeek.MONDAY))
|
||||
assertThat(weekIndexOf(sun, DayOfWeek.SUNDAY))
|
||||
.isEqualTo(weekIndexOf(sun.plus(1, DateTimeUnit.DAY), DayOfWeek.SUNDAY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `indices are non-negative across the supported span`() {
|
||||
// The epoch sits before any date the grid scrolls to, so item indices and
|
||||
// week indices stay the same number — no offset to reconcile.
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
|
||||
assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isGreaterThan(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the list spans 1900 through 2100`() {
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
val count = continuousWeekCount(ws)
|
||||
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)).isLessThan(count)
|
||||
assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isLessThan(count)
|
||||
// ~200 years of weeks, give or take the anchor.
|
||||
assertThat(count).isIn(10_400..10_500)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a window comfortably around the visible range is kept`() {
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nearing a loaded edge widens the window around the visible range`() {
|
||||
// Within four weeks of the top edge → reload, padded on both sides.
|
||||
val widened = nextLoadWindow(loaded = 0..100, firstVisible = 2, lastVisible = 7)
|
||||
assertThat(widened).isEqualTo(-10..19)
|
||||
|
||||
val atBottom = nextLoadWindow(loaded = 0..100, firstVisible = 94, lastVisible = 99)
|
||||
assertThat(atBottom).isEqualTo(82..111)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a jump far outside the window reloads around the destination`() {
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 505))
|
||||
.isEqualTo(488..517)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reloaded window always clears the trigger it just crossed`() {
|
||||
// Otherwise every scroll frame would re-trigger a query.
|
||||
var loaded = 0..100
|
||||
val window = nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)!!
|
||||
loaded = window
|
||||
assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user