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 7f501dc..b5ddc8e 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 @@ -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,19 +200,33 @@ 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 = { - slideDir = when (val s = state) { - is MonthUiState.Success -> - if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1 - else -> 0 + 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 + else -> 0 + } + viewModel.goToToday() } - viewModel.goToToday() } // Drawer jump-to-date: slide from the side the target month lies on. val jumpToDate: (LocalDate) -> Unit = { target -> - slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1 - viewModel.goToDate(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( @@ -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,15 +291,25 @@ fun MonthScreen( ) { WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { - MonthContent( - state = state, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - onSwipeNext = goNext, - onSwipePrev = goPrev, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - ) + if (continuous) { + ContinuousMonthContent( + state = continuousState, + listState = listState, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + ) + } else { + MonthContent( + state = state, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + ) + } } } } @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 24f950c..7e1f3f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -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, ) +/** + * 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, + val weekStart: DayOfWeek, + ) : ContinuousMonthUiState +} + sealed interface MonthUiState { data object Loading : MonthUiState data class Failure(val reason: FailureReason) : MonthUiState 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 87a3f19..1de6dab 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 @@ -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 = 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 = + 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, + instances: List, + ): 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 diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt new file mode 100644 index 0000000..a45a2c3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousWeekIndexTest.kt @@ -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() + } +}