feat(month): add the Dense style, rule under the Continuous header (#38)
Continuous's month blocks make the boundaries legible, but the seams are exactly what someone who wants a dense calendar doesn't want. Both are now offered: - **Dense** is the old flowing layout, kept rather than replaced — one uninterrupted stream of weeks with months running into each other, the 1st naming its month, and the top bar keeping the full month title (there is no sticky header to defer to). - Continuous gains a **rule under its month label**, closing the header off against the grid. On trial: it comes out again if it doesn't earn its place. One provider query serves both. `ContinuousMonthUiState.Success` now carries the same loaded window laid out twice — `monthsByIndex` clipped into blocks, `weeksByIndex` left whole — rather than standing up a second flow and querying the same range again. Both styles report *months* as they scroll, so the window hysteresis is shared; `weekWindowFor` maps a month window onto the Dense rows it covers, widened a week each side so boundary rows don't flicker. The list state is keyed on style *and* week start: Dense indexes by week, which the week-start preference moves, while Continuous indexes by month, which it doesn't — carrying an offset between the two would land somewhere arbitrary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)),
|
||||
// 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))
|
||||
},
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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(
|
||||
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,23 +685,86 @@ 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()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.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()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 20.dp, bottom = 10.dp),
|
||||
.height(CONTINUOUS_ROW_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The month-changing horizontal swipe, shared by the paged and split styles.
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Int, List<MonthWeek>>,
|
||||
val weeksByIndex: Map<Int, MonthWeek>,
|
||||
val weekStart: DayOfWeek,
|
||||
) : ContinuousMonthUiState
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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_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_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_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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user