@@ -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,22 +685,85 @@ 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),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user