@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -19,6 +20,10 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -36,9 +41,12 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -85,11 +93,14 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
@@ -112,8 +123,10 @@ fun MonthScreen(
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val continuousState by viewModel.continuousState.collectAsStateWithLifecycle()
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
@@ -128,17 +141,52 @@ fun MonthScreen(
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isOnCurrentMonth = when (val s = state) {
|
||||
is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month)
|
||||
else -> true
|
||||
val continuous = viewStyle == MonthViewStyle.Continuous
|
||||
|
||||
// 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
|
||||
else -> (state as? MonthUiState.Success)?.today
|
||||
} ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
|
||||
// Keyed on the week start: week indices are anchored on it, so a changed
|
||||
// week start would leave the saved scroll offset pointing at a different week.
|
||||
val listState = key(weekStart) {
|
||||
rememberLazyListState(
|
||||
initialFirstVisibleItemIndex =
|
||||
weekIndexOf(LocalDate(month.year, month.month, 1), weekStart),
|
||||
)
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is MonthUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
// The continuous stream has no single "current" month, so the title takes the
|
||||
// one the viewport mostly sits in: the midweek day of the row below the top
|
||||
// edge, which flips over as that month's first full week comes into view.
|
||||
val visibleMonth by remember(weekStart) {
|
||||
derivedStateOf {
|
||||
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
|
||||
.plus(3, DateTimeUnit.DAY)
|
||||
YearMonth(midweek.year, midweek.month)
|
||||
}
|
||||
}
|
||||
val titleMonth = if (continuous) visibleMonth else month
|
||||
|
||||
// Feed the visible range back so the sliding data window can follow. The
|
||||
// view model ignores everything that doesn't near a loaded edge.
|
||||
LaunchedEffect(listState, continuous) {
|
||||
if (!continuous) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
val visible = listState.layoutInfo.visibleItemsInfo
|
||||
(visible.firstOrNull()?.index ?: 0) to (visible.lastOrNull()?.index ?: 0)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { (first, last) -> viewModel.onVisibleWeeksChanged(first, last) }
|
||||
}
|
||||
|
||||
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
|
||||
|
||||
// Drives whether the title carries the year.
|
||||
val currentYear = today.year
|
||||
|
||||
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
@@ -152,8 +200,13 @@ fun MonthScreen(
|
||||
viewModel.goToPrev()
|
||||
}
|
||||
// Slide toward today: viewing the future → today comes in from the left
|
||||
// (back), viewing the past → from the right (forward).
|
||||
// (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) {
|
||||
scope.launch { listState.animateScrollToItem(weekIndexOf(today, weekStart)) }
|
||||
Unit
|
||||
} else {
|
||||
slideDir = when (val s = state) {
|
||||
is MonthUiState.Success ->
|
||||
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
|
||||
@@ -161,11 +214,20 @@ fun MonthScreen(
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
}
|
||||
// Drawer jump-to-date: slide from the side the target month lies on.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
if (continuous) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(
|
||||
weekIndexOf(LocalDate(target.year, target.month, 1), weekStart),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
|
||||
viewModel.goToDate(target)
|
||||
}
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -174,7 +236,7 @@ fun MonthScreen(
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = LocalDate(month.year, month.month, 1),
|
||||
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -195,7 +257,7 @@ fun MonthScreen(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
MonthTopBar(
|
||||
month = month,
|
||||
month = titleMonth,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
@@ -213,11 +275,9 @@ fun MonthScreen(
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
// Anchor on today when its month is shown, else the 1st.
|
||||
val today = Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
onCreateEvent(
|
||||
if (isOnCurrentMonth) today
|
||||
else LocalDate(month.year, month.month, 1),
|
||||
else LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
null,
|
||||
)
|
||||
},
|
||||
@@ -231,6 +291,15 @@ fun MonthScreen(
|
||||
) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
if (continuous) {
|
||||
ContinuousMonthContent(
|
||||
state = continuousState,
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
} else {
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
@@ -244,6 +313,7 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -303,6 +373,32 @@ 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.
|
||||
*/
|
||||
@Composable
|
||||
private fun ContinuousMonthContent(
|
||||
state: ContinuousMonthUiState,
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
ContinuousMonthUiState.Loading -> MonthGridLoading()
|
||||
is ContinuousMonthUiState.Failure ->
|
||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
is ContinuousMonthUiState.Success -> ContinuousMonthGrid(
|
||||
state = state,
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MonthTopBar(
|
||||
@@ -391,6 +487,14 @@ private val CELL_GAP = 2.dp
|
||||
private val CELL_SHAPE = RoundedCornerShape(12.dp)
|
||||
private const val MAX_EVENT_ROWS = 3
|
||||
|
||||
/**
|
||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||
* however many rows the month has; a scrolling stream has no such bound, so it
|
||||
* fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows —
|
||||
* close to what a five-row month gets on a typical phone.
|
||||
*/
|
||||
private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
||||
|
||||
@Composable
|
||||
private fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
@@ -406,11 +510,12 @@ private fun MonthGrid(
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
val month = state.month
|
||||
state.weeks.forEach { week ->
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = state.today,
|
||||
month = state.month,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = Modifier
|
||||
@@ -421,6 +526,78 @@ private fun MonthGrid(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The continuous grid (#38): one uninterrupted vertical stream of weeks.
|
||||
*
|
||||
* The list is 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.
|
||||
*
|
||||
* Deliberately not a stack of month grids — that would still repeat a boundary
|
||||
* week at the end of one month and the start of the next, which is exactly the
|
||||
* duplication #38 asks to be rid of.
|
||||
*/
|
||||
@Composable
|
||||
private fun ContinuousMonthGrid(
|
||||
state: ContinuousMonthUiState.Success,
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp),
|
||||
) {
|
||||
items(count = weekCount, key = { it }) { index ->
|
||||
val week = state.weeksByIndex[index]
|
||||
if (week == null) {
|
||||
ContinuousWeekPlaceholder()
|
||||
} else {
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = state.today,
|
||||
// Every day in the stream belongs to a month equally — there
|
||||
// is no "other month" to recede here.
|
||||
inMonth = { true },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
labelMonthOnFirst = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(CONTINUOUS_ROW_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
|
||||
@Composable
|
||||
private fun ContinuousWeekPlaceholder() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(CONTINUOUS_ROW_HEIGHT),
|
||||
) {
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
|
||||
* a multi-day event is one connected bar across the columns; single-day timed
|
||||
@@ -432,10 +609,11 @@ private fun MonthGrid(
|
||||
private fun MonthWeekRow(
|
||||
week: MonthWeek,
|
||||
today: LocalDate,
|
||||
month: YearMonth,
|
||||
inMonth: (LocalDate) -> Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
labelMonthOnFirst: Boolean = false,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
@@ -466,14 +644,13 @@ private fun MonthWeekRow(
|
||||
// as one continuous event.
|
||||
Row(Modifier.matchParentSize()) {
|
||||
week.days.forEach { d ->
|
||||
val inMonth = d.month == month.month && d.year == month.year
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(
|
||||
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
|
||||
color = if (inMonth(d)) MaterialTheme.colorScheme.surfaceContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = CELL_SHAPE,
|
||||
),
|
||||
@@ -487,7 +664,15 @@ private fun MonthWeekRow(
|
||||
DayNumberCell(
|
||||
date = d,
|
||||
isToday = d == today,
|
||||
inMonth = d.month == month.month && d.year == month.year,
|
||||
inMonth = inMonth(d),
|
||||
// Continuous has no month boundaries to dim across, so
|
||||
// the 1st names its month instead — the only marker
|
||||
// telling one month from the next inside the stream.
|
||||
monthLabel = if (labelMonthOnFirst && d.day == 1) {
|
||||
shortMonthName(d)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
@@ -620,6 +805,7 @@ private fun DayNumberCell(
|
||||
isToday: Boolean,
|
||||
inMonth: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
monthLabel: String? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.height(DAY_NUMBER_HEIGHT),
|
||||
@@ -639,6 +825,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(),
|
||||
@@ -650,6 +846,16 @@ private fun DayNumberCell(
|
||||
}
|
||||
}
|
||||
|
||||
/** Locale-short month name ("Jul", "Juli"), for the 1st in the continuous 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(
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.YearMonth
|
||||
|
||||
@@ -37,6 +38,23 @@ data class MonthWeek(
|
||||
val countByDay: Map<LocalDate, Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* State for the continuous style (#38). Weeks are keyed by absolute week index
|
||||
* rather than gathered into months: the whole point of the style is that there
|
||||
* are no month boundaries to scroll across, so there is no "current month" here
|
||||
* and no notion of an out-of-month day. [weeksByIndex] holds only the loaded
|
||||
* window; indices outside it render as placeholders until the window catches up.
|
||||
*/
|
||||
sealed interface ContinuousMonthUiState {
|
||||
data object Loading : ContinuousMonthUiState
|
||||
data class Failure(val reason: FailureReason) : ContinuousMonthUiState
|
||||
data class Success(
|
||||
val today: LocalDate,
|
||||
val weeksByIndex: Map<Int, MonthWeek>,
|
||||
val weekStart: DayOfWeek,
|
||||
) : ContinuousMonthUiState
|
||||
}
|
||||
|
||||
sealed interface MonthUiState {
|
||||
data object Loading : MonthUiState
|
||||
data class Failure(val reason: FailureReason) : MonthUiState
|
||||
|
||||
@@ -29,6 +29,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
|
||||
@@ -66,6 +67,14 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** How the grid is laid out and navigated (#38, #53). */
|
||||
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = MonthViewStyle.Paged,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
@@ -91,6 +100,73 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = MonthUiState.Loading,
|
||||
)
|
||||
|
||||
// --- Continuous style (#38) -------------------------------------------
|
||||
//
|
||||
// The continuous grid is one endless stream of weeks, so it can't load "a
|
||||
// month" — it loads a sliding window of week indices around whatever is on
|
||||
// screen. The window only moves when the visible range comes within
|
||||
// WINDOW_EDGE weeks of a loaded edge, so a scroll re-queries occasionally
|
||||
// rather than on every frame.
|
||||
|
||||
// Seeded around today so the first frame has data. The week-start preference
|
||||
// hasn't arrived yet, but anchoring on a different day shifts an index by at
|
||||
// most one week — well inside the pad, and the first scroll report corrects it.
|
||||
private val _loadedWeeks = MutableStateFlow(
|
||||
weekIndexOf(todayDate, DayOfWeek.MONDAY).let { it - WINDOW_PAD..it + WINDOW_PAD },
|
||||
)
|
||||
|
||||
val continuousState: StateFlow<ContinuousMonthUiState> =
|
||||
combine(_loadedWeeks, weekStart) { window, ws -> window to ws }
|
||||
.flatMapLatest { (window, ws) ->
|
||||
val first = weekStartForIndex(window.first, ws)
|
||||
val last = weekStartForIndex(window.last, ws).plus(6, DateTimeUnit.DAY)
|
||||
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildContinuousState(window, ws, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = ContinuousMonthUiState.Loading,
|
||||
)
|
||||
|
||||
/**
|
||||
* Report which absolute week indices are on screen. Cheap to call on every
|
||||
* scroll frame: it returns immediately unless the visible range has drifted
|
||||
* close enough to a loaded edge to warrant a wider query.
|
||||
*/
|
||||
fun onVisibleWeeksChanged(firstIndex: Int, lastIndex: Int) {
|
||||
nextLoadWindow(_loadedWeeks.value, firstIndex, lastIndex)?.let { _loadedWeeks.value = it }
|
||||
}
|
||||
|
||||
private fun buildContinuousState(
|
||||
window: IntRange,
|
||||
weekStart: DayOfWeek,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): ContinuousMonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val weeks = window.associateWith { index ->
|
||||
val days = (0 until 7).map {
|
||||
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||
}
|
||||
layoutCalendarWeek(days, instances, zone)
|
||||
}
|
||||
return ContinuousMonthUiState.Success(
|
||||
today = todayDate,
|
||||
weeksByIndex = weeks,
|
||||
weekStart = weekStart,
|
||||
)
|
||||
}
|
||||
|
||||
fun goToPrev() {
|
||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||
}
|
||||
@@ -225,6 +301,52 @@ internal fun monthGridRange(
|
||||
return start..end
|
||||
}
|
||||
|
||||
/**
|
||||
* How many weeks the continuous window loads beyond the visible range, and how
|
||||
* close the visible range may drift to a loaded edge before it reloads. The pad
|
||||
* is generous relative to the trigger so a steady scroll crosses the trigger
|
||||
* well before it would run out of laid-out weeks.
|
||||
*/
|
||||
private const val WINDOW_PAD = 12
|
||||
private const val WINDOW_EDGE = 4
|
||||
|
||||
/**
|
||||
* The window to load for a visible range, or null to keep the current one.
|
||||
*
|
||||
* Kept pure and separate from the view model so the hysteresis — the reason a
|
||||
* scroll doesn't re-query the provider on every frame — is testable on its own.
|
||||
*/
|
||||
internal fun nextLoadWindow(loaded: IntRange, firstVisible: Int, lastVisible: Int): IntRange? {
|
||||
val comfortablyInside =
|
||||
firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last
|
||||
if (comfortablyInside) return null
|
||||
return (firstVisible - WINDOW_PAD)..(lastVisible + WINDOW_PAD)
|
||||
}
|
||||
|
||||
/**
|
||||
* The continuous grid addresses weeks by an absolute index rather than a
|
||||
* (month, row) pair, so the list has one stable, gap-free coordinate space to
|
||||
* scroll through and key its items by. Index 0 is the first week of 1900 under
|
||||
* the active week-start; the list runs to [CONTINUOUS_WEEK_COUNT].
|
||||
*
|
||||
* The epoch is deliberately far in the past so every index is non-negative,
|
||||
* which keeps the LazyColumn's item indices and week indices the same number.
|
||||
*/
|
||||
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 continuous grid scrolls through (1900 → 2100). */
|
||||
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||
|
||||
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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.plus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The continuous grid's coordinate space. Every row's identity — and the
|
||||
* LazyColumn item it maps to — hangs off this arithmetic, so it gets its own
|
||||
* tests rather than being exercised only through the UI.
|
||||
*/
|
||||
class ContinuousWeekIndexTest {
|
||||
|
||||
// 2026-06-08 is a Monday; 2026-06-10 the Wednesday of the same week.
|
||||
private val mon = LocalDate(2026, 6, 8)
|
||||
private val wed = LocalDate(2026, 6, 10)
|
||||
|
||||
@Test
|
||||
fun `every day of a week shares one index`() {
|
||||
val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
|
||||
assertThat(indices.toSet()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consecutive weeks are consecutive indices`() {
|
||||
val a = weekIndexOf(mon, DayOfWeek.MONDAY)
|
||||
val b = weekIndexOf(mon.plus(7, DateTimeUnit.DAY), DayOfWeek.MONDAY)
|
||||
val back = weekIndexOf(mon.plus(-7, DateTimeUnit.DAY), DayOfWeek.MONDAY)
|
||||
assertThat(b).isEqualTo(a + 1)
|
||||
assertThat(back).isEqualTo(a - 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `index round-trips back to the week's first day`() {
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
val index = weekIndexOf(wed, ws)
|
||||
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the week start shifts which week a boundary day belongs to`() {
|
||||
// Sunday the 14th closes the Monday-anchored week but opens the Sunday one.
|
||||
val sun = LocalDate(2026, 6, 14)
|
||||
assertThat(weekIndexOf(sun, DayOfWeek.MONDAY))
|
||||
.isEqualTo(weekIndexOf(mon, DayOfWeek.MONDAY))
|
||||
assertThat(weekIndexOf(sun, DayOfWeek.SUNDAY))
|
||||
.isEqualTo(weekIndexOf(sun.plus(1, DateTimeUnit.DAY), DayOfWeek.SUNDAY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `indices are non-negative across the supported span`() {
|
||||
// The epoch sits before any date the grid scrolls to, so item indices and
|
||||
// week indices stay the same number — no offset to reconcile.
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
|
||||
assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isGreaterThan(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the list spans 1900 through 2100`() {
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
val count = continuousWeekCount(ws)
|
||||
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)).isLessThan(count)
|
||||
assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isLessThan(count)
|
||||
// ~200 years of weeks, give or take the anchor.
|
||||
assertThat(count).isIn(10_400..10_500)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a window comfortably around the visible range is kept`() {
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nearing a loaded edge widens the window around the visible range`() {
|
||||
// Within four weeks of the top edge → reload, padded on both sides.
|
||||
val widened = nextLoadWindow(loaded = 0..100, firstVisible = 2, lastVisible = 7)
|
||||
assertThat(widened).isEqualTo(-10..19)
|
||||
|
||||
val atBottom = nextLoadWindow(loaded = 0..100, firstVisible = 94, lastVisible = 99)
|
||||
assertThat(atBottom).isEqualTo(82..111)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a jump far outside the window reloads around the destination`() {
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 505))
|
||||
.isEqualTo(488..517)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reloaded window always clears the trigger it just crossed`() {
|
||||
// Otherwise every scroll frame would re-trigger a query.
|
||||
var loaded = 0..100
|
||||
val window = nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)!!
|
||||
loaded = window
|
||||
assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user