@@ -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,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()
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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