feat(month): month view style picker + Split pull-to-expand (#38, #53) #90

Merged
makiolaj merged 36 commits from feat/month-view-style into release/v2.16.0 2026-07-20 21:25:50 +00:00
7 changed files with 339 additions and 55 deletions
Showing only changes of commit 24b005126e - Show all commits

View File

@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -48,6 +49,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -103,9 +105,11 @@ import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -149,42 +153,61 @@ fun MonthScreen(
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() 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 // Today, from whichever state is driving; the clock only covers the first
// frame, before either has loaded. // frame, before either has loaded.
val today = when { val today = when {
continuous -> (continuousState as? ContinuousMonthUiState.Success)?.today scrolling -> (continuousState as? ContinuousMonthUiState.Success)?.today
else -> (state as? MonthUiState.Success)?.today else -> (state as? MonthUiState.Success)?.today
} ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
// Month indices don't move with the week-start preference, so unlike the // The two scrolling styles index their items differently — Continuous by
// week-indexed stream this replaced, the list state survives a change to it. // month (which the week start can't move), Dense by week (which it can) — so
val listState = rememberLazyListState( // the list state is rebuilt when either changes rather than carrying an
initialFirstVisibleItemIndex = itemIndexForMonth(monthIndexOf(month)), // 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))
},
) )
// 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)) }
} }
val titleMonth = if (continuous) visibleMonth else month
// Feed the visible range back so the sliding data window can follow. The // Which month the viewport is showing. Continuous reads it off the block
// view model ignores everything that doesn't near a loaded edge. // whose sticky header is up; Dense has no blocks, so it takes the month the
LaunchedEffect(listState, continuous) { // row below the top edge mostly sits in, which flips as that month's first
if (!continuous) return@LaunchedEffect // 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 { snapshotFlow {
// Empty before the first layout pass — and reporting a default of 0 // 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 // for it would claim January 1900 is on screen and drag the whole
// window back there. // window back there.
val visible = listState.layoutInfo.visibleItemsInfo val visible = listState.layoutInfo.visibleItemsInfo
if (visible.isEmpty()) { when {
null visible.isEmpty() -> null
} else { dense -> monthIndexForWeek(visible.first().index, weekStart) to
monthIndexForItem(visible.first().index) to monthIndexForWeek(visible.last().index, weekStart)
else -> monthIndexForItem(visible.first().index) to
monthIndexForItem(visible.last().index) monthIndexForItem(visible.last().index)
} }
} }
@@ -195,10 +218,11 @@ fun MonthScreen(
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
// The continuous style names the month on the block's own sticky header, so // Continuous names the month on the block's own sticky header, so the bar
// the bar carries the year instead of repeating it two lines further down. // 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 locale = currentLocale()
val topBarTitle = if (continuous) { val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
titleMonth.year.toString() titleMonth.year.toString()
} else { } else {
formatMonthTitle(titleMonth, locale, currentYear = today.year) formatMonthTitle(titleMonth, locale, currentYear = today.year)
@@ -219,10 +243,11 @@ fun MonthScreen(
// (back), viewing the past → from the right (forward). The continuous stream // (back), viewing the past → from the right (forward). The continuous stream
// scrolls to today's week instead — there is nothing to slide. // scrolls to today's week instead — there is nothing to slide.
val jumpToToday = { val jumpToToday = {
if (continuous) { if (scrolling) {
scope.launch { scope.launch {
listState.animateScrollToItem( listState.animateScrollToItem(
itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), if (dense) weekIndexOf(today, weekStart)
else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))),
) )
} }
Unit Unit
@@ -237,10 +262,11 @@ fun MonthScreen(
} }
// Drawer jump-to-date: slide from the side the target month lies on. // Drawer jump-to-date: slide from the side the target month lies on.
val jumpToDate: (LocalDate) -> Unit = { target -> val jumpToDate: (LocalDate) -> Unit = { target ->
if (continuous) { if (scrolling) {
scope.launch { scope.launch {
listState.animateScrollToItem( 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 { } else {
@@ -310,10 +336,11 @@ fun MonthScreen(
) { ) {
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
if (continuous) { if (scrolling) {
ContinuousMonthContent( ContinuousMonthContent(
state = continuousState, state = continuousState,
listState = listState, listState = listState,
dense = dense,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onRetry = jumpToToday, onRetry = jumpToToday,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -389,14 +416,15 @@ private fun MonthContent(
} }
/** /**
* Continuous style content. No swipe detector and no [AnimatedContent]: vertical * Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
* scrolling owns the gesture, and the list never swaps wholesale — it keeps * vertical scrolling owns the gesture, and the list never swaps wholesale — it
* scrolling while the window behind it reloads. * keeps scrolling while the window behind it reloads.
*/ */
@Composable @Composable
private fun ContinuousMonthContent( private fun ContinuousMonthContent(
state: ContinuousMonthUiState, state: ContinuousMonthUiState,
listState: LazyListState, listState: LazyListState,
dense: Boolean,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onRetry: () -> Unit, onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
@@ -405,12 +433,21 @@ private fun ContinuousMonthContent(
ContinuousMonthUiState.Loading -> MonthGridLoading() ContinuousMonthUiState.Loading -> MonthGridLoading()
is ContinuousMonthUiState.Failure -> is ContinuousMonthUiState.Failure ->
CalendarFailure(reason = state.reason, onRetry = onRetry) CalendarFailure(reason = state.reason, onRetry = onRetry)
is ContinuousMonthUiState.Success -> ContinuousMonthGrid( is ContinuousMonthUiState.Success -> if (dense) {
DenseMonthGrid(
state = state, state = state,
listState = listState, listState = listState,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
) )
} else {
ContinuousMonthGrid(
state = state,
listState = listState,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
)
}
} }
} }
@@ -648,23 +685,86 @@ private fun ContinuousMonthBlock(
/** /**
* The month label that pins above its block. Opaque `surface` so the rows scroll * 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 * 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 @Composable
private fun ContinuousMonthHeader(month: YearMonth, currentYear: Int, isCurrent: Boolean) { private fun ContinuousMonthHeader(month: YearMonth, currentYear: Int, isCurrent: Boolean) {
val locale = currentLocale() val locale = currentLocale()
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(top = 20.dp),
) {
Text( Text(
text = formatMonthTitle(month, locale, currentYear), text = formatMonthTitle(month, locale, currentYear),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = if (isCurrent) MaterialTheme.colorScheme.primary color = if (isCurrent) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface, 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface) .height(CONTINUOUS_ROW_HEIGHT),
.padding(horizontal = 12.dp)
.padding(top = 20.dp, bottom = 10.dp),
) )
} }
}
}
}
/** /**
* The month-changing horizontal swipe, shared by the paged and split styles. * The month-changing horizontal swipe, shared by the paged and split styles.
@@ -970,6 +1070,7 @@ private fun MonthWeekRow(
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
blankOutside: Boolean = false, blankOutside: Boolean = false,
labelMonthOnFirst: Boolean = false,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
@@ -1029,6 +1130,13 @@ private fun MonthWeekRow(
date = d, date = d,
isToday = d == today, isToday = d == today,
inMonth = inMonth(d), 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), modifier = Modifier.weight(1f),
) )
} }
@@ -1167,6 +1275,7 @@ private fun DayNumberCell(
isToday: Boolean, isToday: Boolean,
inMonth: Boolean, inMonth: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
monthLabel: String? = null,
) { ) {
Box( Box(
modifier = modifier.height(DAY_NUMBER_HEIGHT), modifier = modifier.height(DAY_NUMBER_HEIGHT),
@@ -1186,6 +1295,16 @@ private fun DayNumberCell(
color = MaterialTheme.colorScheme.onPrimary, 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 { } else {
Text( Text(
text = date.day.toString(), 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. */ /** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
@Composable @Composable
private fun MonthBar( private fun MonthBar(

View File

@@ -119,6 +119,18 @@ internal fun MonthStylePreview(
showWeekNumbers = false, showWeekNumbers = false,
onOpenDay = {}, 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 -> { MonthViewStyle.Split -> {
SplitMonthGrid( SplitMonthGrid(
state = sample.month, state = sample.month,
@@ -182,16 +194,23 @@ private fun sampleMonthState(
zone = zone, zone = zone,
) )
// This month and the next: enough for the preview to show a month block, the // The month either side of this one: enough for Continuous to show a block,
// whitespace under it and the following month's header coming up. // 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 centre = monthIndexOf(ym)
val window = centre..(centre + 1) val window = (centre - 1)..(centre + 1)
val continuous = ContinuousMonthUiState.Success( val continuous = ContinuousMonthUiState.Success(
today = today, today = today,
monthsByIndex = window.associateWith { index -> monthsByIndex = window.associateWith { index ->
val m = yearMonthForIndex(index) val m = yearMonthForIndex(index)
layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) } 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, weekStart = weekStart,
) )
return SampleMonth(month, continuous) return SampleMonth(month, continuous)

View File

@@ -46,8 +46,17 @@ data class MonthWeek(
* its seven columns so the grid geometry never shifts, but the neighbour month's * 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. * dates are clipped out of it (see `clipWeekToMonth`) and render as blanks.
* *
* [monthsByIndex] holds only the loaded window; indices outside it render as * The same loaded window is served two ways, because the Dense style is the same
* placeholders until the window catches up. * 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 { sealed interface ContinuousMonthUiState {
data object Loading : ContinuousMonthUiState data object Loading : ContinuousMonthUiState
@@ -55,6 +64,7 @@ sealed interface ContinuousMonthUiState {
data class Success( data class Success(
val today: LocalDate, val today: LocalDate,
val monthsByIndex: Map<Int, List<MonthWeek>>, val monthsByIndex: Map<Int, List<MonthWeek>>,
val weeksByIndex: Map<Int, MonthWeek>,
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
) : ContinuousMonthUiState ) : ContinuousMonthUiState
} }

View File

@@ -30,6 +30,7 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
@@ -165,9 +166,18 @@ class MonthViewModel @Inject constructor(
val ym = yearMonthForIndex(index) val ym = yearMonthForIndex(index)
layoutMonthWeeks(ym, weekStart, instances, zone).map { clipWeekToMonth(it, ym) } 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( return ContinuousMonthUiState.Success(
today = todayDate, today = todayDate,
monthsByIndex = months, monthsByIndex = months,
weeksByIndex = weeks,
weekStart = weekStart, weekStart = weekStart,
) )
} }
@@ -415,6 +425,52 @@ internal fun clampMonthWindow(window: IntRange): IntRange {
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1) 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. */ /** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int { internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
val first = firstOfMonth(ym) val first = firstOfMonth(ym)

View File

@@ -19,20 +19,34 @@ enum class MonthViewStyle {
Paged, Paged,
/** /**
* One uninterrupted vertical stream of weeks. Each date appears exactly once, * Months stacked into one vertical scroll, each a self-contained block under
* which is the point — paging repeats a boundary week at both ends (#38). * its own sticky header: a block shows only its own days, and whitespace
* separates it from the next (#38).
*/ */
Continuous, 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). */ /** Compact dots-only grid over a list of the selected day's events (#53). */
Split, 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 @get:StringRes
val MonthViewStyle.labelRes: Int val MonthViewStyle.labelRes: Int
get() = when (this) { get() = when (this) {
MonthViewStyle.Paged -> R.string.month_style_paged MonthViewStyle.Paged -> R.string.month_style_paged
MonthViewStyle.Continuous -> R.string.month_style_continuous MonthViewStyle.Continuous -> R.string.month_style_continuous
MonthViewStyle.Dense -> R.string.month_style_dense
MonthViewStyle.Split -> R.string.month_style_split MonthViewStyle.Split -> R.string.month_style_split
} }
@@ -41,5 +55,6 @@ val MonthViewStyle.descriptionRes: Int
get() = when (this) { get() = when (this) {
MonthViewStyle.Paged -> R.string.month_style_paged_summary MonthViewStyle.Paged -> R.string.month_style_paged_summary
MonthViewStyle.Continuous -> R.string.month_style_continuous_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 MonthViewStyle.Split -> R.string.month_style_split_summary
} }

View File

@@ -380,6 +380,8 @@
<string name="month_style_paged_summary">One month at a time. Swipe sideways to change month.</string> <string name="month_style_paged_summary">One month at a time. Swipe sideways to change month.</string>
<string name="month_style_continuous">Continuous</string> <string name="month_style_continuous">Continuous</string>
<string name="month_style_continuous_summary">Scroll up and down through the months. Each month keeps to itself under its own heading, with no days borrowed from its neighbours.</string> <string name="month_style_continuous_summary">Scroll up and down through the months. Each month keeps to itself under its own heading, with no days borrowed from its neighbours.</string>
<string name="month_style_dense">Dense</string>
<string name="month_style_dense_summary">The same endless scroll with the seams taken out — one unbroken run of weeks, months flowing into each other.</string>
<string name="month_style_split">Split</string> <string name="month_style_split">Split</string>
<string name="month_style_split_summary">A compact grid with dots for events, and the day you tap listed underneath.</string> <string name="month_style_split_summary">A compact grid with dots for events, and the day you tap listed underneath.</string>
<string name="month_split_no_events">Nothing scheduled</string> <string name="month_split_no_events">Nothing scheduled</string>

View File

@@ -1,11 +1,14 @@
package de.jeanlucmakiola.calendula.ui.month package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import org.junit.jupiter.api.Test 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 @Test
fun `firstOfMonth is the 1st`() { fun `firstOfMonth is the 1st`() {
assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1)) assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))