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 a45ab09..cbca6d4 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape @@ -48,6 +49,7 @@ 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 @@ -103,9 +105,11 @@ import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus import kotlinx.datetime.YearMonth import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime @@ -149,42 +153,61 @@ fun MonthScreen( val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - val continuous = viewStyle == MonthViewStyle.Continuous + val scrolling = viewStyle.isScrolling + val dense = viewStyle == MonthViewStyle.Dense // 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 + scrolling -> (continuousState as? ContinuousMonthUiState.Success)?.today else -> (state as? MonthUiState.Success)?.today } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - // Month indices don't move with the week-start preference, so unlike the - // week-indexed stream this replaced, the list state survives a change to it. - val listState = rememberLazyListState( - initialFirstVisibleItemIndex = itemIndexForMonth(monthIndexOf(month)), - ) - - // Whichever month's block the top of the viewport is in. Its sticky header - // is the first visible item for all but the last sliver of a block, so this - // flips exactly when the header does. - val visibleMonth by remember { - derivedStateOf { yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) } + // The two scrolling styles index their items differently — Continuous by + // month (which the week start can't move), Dense by week (which it can) — so + // the list state is rebuilt when either changes rather than carrying an + // offset into a coordinate space that no longer means the same thing. + val listState = key(viewStyle, weekStart) { + rememberLazyListState( + initialFirstVisibleItemIndex = if (dense) { + weekIndexOf(LocalDate(month.year, month.month, 1), weekStart) + } else { + itemIndexForMonth(monthIndexOf(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 + // Which month the viewport is showing. Continuous reads it off the block + // whose sticky header is up; Dense has no blocks, so it takes the month the + // row below the top edge mostly sits in, which flips as that month's first + // full week comes into view. + val visibleMonth by remember(dense, weekStart) { + derivedStateOf { + if (dense) { + val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart) + .plus(3, DateTimeUnit.DAY) + YearMonth(midweek.year, midweek.month) + } else { + yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) + } + } + } + val titleMonth = if (scrolling) visibleMonth else month + + // Feed the visible range back so the sliding data window can follow. Both + // styles report *months*, whatever they index by — one window serves both. + LaunchedEffect(listState, scrolling, dense, weekStart) { + if (!scrolling) return@LaunchedEffect snapshotFlow { // Empty before the first layout pass — and reporting a default of 0 // for it would claim January 1900 is on screen and drag the whole // window back there. val visible = listState.layoutInfo.visibleItemsInfo - if (visible.isEmpty()) { - null - } else { - monthIndexForItem(visible.first().index) to + when { + visible.isEmpty() -> null + dense -> monthIndexForWeek(visible.first().index, weekStart) to + monthIndexForWeek(visible.last().index, weekStart) + else -> monthIndexForItem(visible.first().index) to monthIndexForItem(visible.last().index) } } @@ -195,10 +218,11 @@ fun MonthScreen( val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) - // The continuous style names the month on the block's own sticky header, so - // the bar carries the year instead of repeating it two lines further down. + // Continuous names the month on the block's own sticky header, so the bar + // carries the year instead of repeating it two lines further down. Dense has + // no header to defer to, so it keeps the full title. val locale = currentLocale() - val topBarTitle = if (continuous) { + val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) { titleMonth.year.toString() } else { formatMonthTitle(titleMonth, locale, currentYear = today.year) @@ -219,10 +243,11 @@ fun MonthScreen( // (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) { + if (scrolling) { scope.launch { listState.animateScrollToItem( - itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), + if (dense) weekIndexOf(today, weekStart) + else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), ) } Unit @@ -237,10 +262,11 @@ fun MonthScreen( } // Drawer jump-to-date: slide from the side the target month lies on. val jumpToDate: (LocalDate) -> Unit = { target -> - if (continuous) { + if (scrolling) { scope.launch { listState.animateScrollToItem( - itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), + if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) + else itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), ) } } else { @@ -310,10 +336,11 @@ fun MonthScreen( ) { WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { - if (continuous) { + if (scrolling) { ContinuousMonthContent( state = continuousState, listState = listState, + dense = dense, showWeekNumbers = showWeekNumbers, onRetry = jumpToToday, onOpenDay = onOpenDay, @@ -389,14 +416,15 @@ 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. + * Content for both scrolling styles. 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, + dense: Boolean, showWeekNumbers: Boolean, onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, @@ -405,12 +433,21 @@ private fun ContinuousMonthContent( ContinuousMonthUiState.Loading -> MonthGridLoading() is ContinuousMonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) - is ContinuousMonthUiState.Success -> ContinuousMonthGrid( - state = state, - listState = listState, - showWeekNumbers = showWeekNumbers, - onOpenDay = onOpenDay, - ) + is ContinuousMonthUiState.Success -> if (dense) { + DenseMonthGrid( + state = state, + listState = listState, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + ) + } else { + ContinuousMonthGrid( + state = state, + listState = listState, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + ) + } } } @@ -648,22 +685,85 @@ private fun ContinuousMonthBlock( /** * The month label that pins above its block. Opaque `surface` so the rows scroll * under it cleanly, and generous vertical padding — the whitespace around the - * label is what makes each month read as its own section. + * label is what makes each month read as its own section. The rule beneath it + * closes the header off against the grid; it is the one part of this layout the + * user is still deciding on. */ @Composable private fun ContinuousMonthHeader(month: YearMonth, currentYear: Int, isCurrent: Boolean) { val locale = currentLocale() - Text( - text = formatMonthTitle(month, locale, currentYear), - style = MaterialTheme.typography.titleMedium, - color = if (isCurrent) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurface, + Column( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 12.dp) - .padding(top = 20.dp, bottom = 10.dp), - ) + .padding(top = 20.dp), + ) { + Text( + text = formatMonthTitle(month, locale, currentYear), + style = MaterialTheme.typography.titleMedium, + color = if (isCurrent) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 0.dp), + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), + ) + } +} + +/** + * The Dense style (#38): one uninterrupted vertical stream of weeks, months + * flowing into each other. + * + * Kept alongside the block layout rather than replaced by it — the seams that + * make months legible are exactly what someone wanting a dense calendar doesn't + * want. There is no month boundary to dim across here, so every day reads as + * in-month and the 1st names its month: the only marker of where one ends. + * + * 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. + */ +@Composable +internal fun DenseMonthGrid( + 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), + ) + } + } + } } /** @@ -970,6 +1070,7 @@ private fun MonthWeekRow( onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, blankOutside: Boolean = false, + labelMonthOnFirst: Boolean = false, ) { val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -1029,6 +1130,13 @@ private fun MonthWeekRow( date = d, isToday = d == today, inMonth = inMonth(d), + // Dense has no month boundaries to dim across and + // no header to name the month, so the 1st does it. + monthLabel = if (labelMonthOnFirst && d.day == 1) { + shortMonthName(d) + } else { + null + }, modifier = Modifier.weight(1f), ) } @@ -1167,6 +1275,7 @@ private fun DayNumberCell( isToday: Boolean, inMonth: Boolean, modifier: Modifier = Modifier, + monthLabel: String? = null, ) { Box( modifier = modifier.height(DAY_NUMBER_HEIGHT), @@ -1186,6 +1295,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(), @@ -1197,6 +1316,16 @@ private fun DayNumberCell( } } +/** Locale-short month name ("Jul", "Juli"), for the 1st in the Dense 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/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index d113cf3..c75a223 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -119,6 +119,18 @@ internal fun MonthStylePreview( showWeekNumbers = false, onOpenDay = {}, ) + MonthViewStyle.Dense -> DenseMonthGrid( + state = sample.continuous, + listState = rememberLazyListState( + // Start a week above today's, so the preview shows a + // stream running past the viewport rather than one + // beginning at its top edge. + initialFirstVisibleItemIndex = + weekIndexOf(today, weekStart) - 1, + ), + showWeekNumbers = false, + onOpenDay = {}, + ) MonthViewStyle.Split -> { SplitMonthGrid( state = sample.month, @@ -182,16 +194,23 @@ private fun sampleMonthState( zone = zone, ) - // This month and the next: enough for the preview to show a month block, the - // whitespace under it and the following month's header coming up. + // The month either side of this one: enough for Continuous to show a block, + // the whitespace under it and the next month's header coming up, and for + // Dense to have somewhere to scroll in both directions. val centre = monthIndexOf(ym) - val window = centre..(centre + 1) + val window = (centre - 1)..(centre + 1) val continuous = ContinuousMonthUiState.Success( today = today, monthsByIndex = window.associateWith { index -> val m = yearMonthForIndex(index) layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) } }, + weeksByIndex = weekWindowFor(window, weekStart).associateWith { index -> + val days = (0 until 7).map { + weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) + } + layoutCalendarWeek(days, events, zone) + }, weekStart = weekStart, ) return SampleMonth(month, continuous) 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 91ff84a..564466b 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 @@ -46,8 +46,17 @@ data class MonthWeek( * its seven columns so the grid geometry never shifts, but the neighbour month's * dates are clipped out of it (see `clipWeekToMonth`) and render as blanks. * - * [monthsByIndex] holds only the loaded window; indices outside it render as - * placeholders until the window catches up. + * The same loaded window is served two ways, because the Dense style is the same + * scroll with the seams taken out and it would be wasteful to query the provider + * twice for one range: + * + * - [monthsByIndex] — the block layout, keyed by absolute month index, each week + * clipped to its own month. + * - [weeksByIndex] — the flowing layout, keyed by absolute week index, boundary + * weeks left carrying both months. + * + * Both hold only the loaded window; indices outside it render as placeholders + * until the window catches up. */ sealed interface ContinuousMonthUiState { data object Loading : ContinuousMonthUiState @@ -55,6 +64,7 @@ sealed interface ContinuousMonthUiState { data class Success( val today: LocalDate, val monthsByIndex: Map>, + val weeksByIndex: Map, val weekStart: DayOfWeek, ) : ContinuousMonthUiState } 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 d94d4b5..db574b5 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 @@ -30,6 +30,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 @@ -165,9 +166,18 @@ class MonthViewModel @Inject constructor( val ym = yearMonthForIndex(index) layoutMonthWeeks(ym, weekStart, instances, zone).map { clipWeekToMonth(it, ym) } } + // The Dense style's rows, from the same query: every week the window's + // months touch, laid out whole rather than clipped to a month. + val weeks = weekWindowFor(window, weekStart).associateWith { index -> + val days = (0 until 7).map { + weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) + } + layoutCalendarWeek(days, instances, zone) + } return ContinuousMonthUiState.Success( today = todayDate, monthsByIndex = months, + weeksByIndex = weeks, weekStart = weekStart, ) } @@ -415,6 +425,52 @@ internal fun clampMonthWindow(window: IntRange): IntRange { internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1) +/** + * The Dense style addresses *weeks* by absolute index instead: it has no month + * blocks to key by, and a row's identity has to survive the months around it + * scrolling past. Index 0 is the first week of 1900 under the active week-start, + * so — like the month space — every index is non-negative and the LazyColumn's + * item indices and week indices are the same number. + * + * Unlike month indices these *do* shift with the week-start preference, which is + * why the list state is keyed on it. + */ +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 Dense grid scrolls through (1900 → 2100). */ +internal fun continuousWeekCount(weekStart: DayOfWeek): Int = + weekIndexOf(WEEK_INDEX_END, weekStart) + 1 + +/** Which month a Dense row belongs to, for reporting the visible range. */ +internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int { + // The row's midweek day: a boundary week is reported as whichever month owns + // most of it, the same rule the title uses. + val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY) + return monthIndexOf(YearMonth(midweek.year, midweek.month)) +} + +/** + * Every week index the months in [window] touch — the Dense rows one month-window + * load covers. Widened by a week at each end so the boundary rows either side are + * laid out too, rather than flickering as placeholders. + */ +internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange { + val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1 + val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last)) + .plus(1, DateTimeUnit.MONTH) + .minus(1, DateTimeUnit.DAY) + return first..(weekIndexOf(lastMonthEnd, weekStart) + 1) +} + /** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */ internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int { val first = firstOfMonth(ym) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt index 6b636a4..2f553c8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt @@ -19,20 +19,34 @@ enum class MonthViewStyle { Paged, /** - * One uninterrupted vertical stream of weeks. Each date appears exactly once, - * which is the point — paging repeats a boundary week at both ends (#38). + * Months stacked into one vertical scroll, each a self-contained block under + * its own sticky header: a block shows only its own days, and whitespace + * separates it from the next (#38). */ Continuous, + /** + * [Continuous] with the seams taken out: one uninterrupted stream of weeks + * where months flow into each other. Each date appears exactly once — paging + * repeats a boundary week at both ends — and the 1st names its month, the + * only marker of where one ends. + */ + Dense, + /** Compact dots-only grid over a list of the selected day's events (#53). */ Split, } +/** Both vertically scrolling styles, which share a data window and a list state. */ +val MonthViewStyle.isScrolling: Boolean + get() = this == MonthViewStyle.Continuous || this == MonthViewStyle.Dense + @get:StringRes val MonthViewStyle.labelRes: Int get() = when (this) { MonthViewStyle.Paged -> R.string.month_style_paged MonthViewStyle.Continuous -> R.string.month_style_continuous + MonthViewStyle.Dense -> R.string.month_style_dense MonthViewStyle.Split -> R.string.month_style_split } @@ -41,5 +55,6 @@ val MonthViewStyle.descriptionRes: Int get() = when (this) { MonthViewStyle.Paged -> R.string.month_style_paged_summary MonthViewStyle.Continuous -> R.string.month_style_continuous_summary + MonthViewStyle.Dense -> R.string.month_style_dense_summary MonthViewStyle.Split -> R.string.month_style_split_summary } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5bd8dd6..bdd8be4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -380,6 +380,8 @@ One month at a time. Swipe sideways to change month. Continuous Scroll up and down through the months. Each month keeps to itself under its own heading, with no days borrowed from its neighbours. + Dense + The same endless scroll with the seams taken out — one unbroken run of weeks, months flowing into each other. Split A compact grid with dots for events, and the day you tap listed underneath. Nothing scheduled 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 c637c3d..7abfc13 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 @@ -1,11 +1,14 @@ 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.Month import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth +import kotlinx.datetime.minus +import kotlinx.datetime.plus import org.junit.jupiter.api.Test /** @@ -107,6 +110,56 @@ class ContinuousMonthIndexTest { } } + @Test + fun `the dense week window covers every week its months touch`() { + // One month-window load has to lay out every Dense row those months + // appear in, boundary weeks included, or rows would flicker as + // placeholders while the data for them is already in hand. + DayOfWeek.entries.forEach { ws -> + val window = monthIndexOf(jun26)..monthIndexOf(YearMonth(2026, Month.AUGUST)) + val weeks = weekWindowFor(window, ws) + window.forEach { index -> + val ym = yearMonthForIndex(index) + val first = weekIndexOf(firstOfMonth(ym), ws) + val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws) + assertThat(weeks.contains(first)).isTrue() + assertThat(weeks.contains(last)).isTrue() + } + } + } + + @Test + fun `a dense row reports the month that owns most of it`() { + // June 29 – July 5, 2026 (Monday-anchored): four of its days are July's, + // so the window follows July rather than snapping back to June. + val boundary = weekIndexOf(LocalDate(2026, 6, 29), DayOfWeek.MONDAY) + assertThat(monthIndexForWeek(boundary, DayOfWeek.MONDAY)) + .isEqualTo(monthIndexOf(YearMonth(2026, Month.JULY))) + // A row wholly inside June reports June. + assertThat(monthIndexForWeek(weekIndexOf(LocalDate(2026, 6, 15), DayOfWeek.MONDAY), DayOfWeek.MONDAY)) + .isEqualTo(monthIndexOf(jun26)) + } + + @Test + fun `every day of a dense week shares one index`() { + val mon = LocalDate(2026, 6, 8) + val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } + assertThat(indices.toSet()).hasSize(1) + } + + @Test + fun `dense week indices round-trip and stay inside the list`() { + val wed = LocalDate(2026, 6, 10) + DayOfWeek.entries.forEach { ws -> + val index = weekIndexOf(wed, ws) + assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws)) + assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0) + assertThat(index).isLessThan(continuousWeekCount(ws)) + assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)) + .isLessThan(continuousWeekCount(ws)) + } + } + @Test fun `firstOfMonth is the 1st`() { assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))