@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -36,9 +41,12 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
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.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
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.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -112,8 +123,10 @@ fun MonthScreen(
|
|||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val continuousState by viewModel.continuousState.collectAsStateWithLifecycle()
|
||||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle()
|
||||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
||||||
// The instant before which an event counts as completed, or null when dimming
|
// 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 drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val isOnCurrentMonth = when (val s = state) {
|
val continuous = viewStyle == MonthViewStyle.Continuous
|
||||||
is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month)
|
|
||||||
else -> true
|
// 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
|
// The continuous stream has no single "current" month, so the title takes the
|
||||||
// while the first load is in flight, when there is no state to read today from.
|
// one the viewport mostly sits in: the midweek day of the row below the top
|
||||||
val currentYear = when (val s = state) {
|
// edge, which flips over as that month's first full week comes into view.
|
||||||
is MonthUiState.Success -> s.today.year
|
val visibleMonth by remember(weekStart) {
|
||||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
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).
|
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
@@ -152,19 +200,33 @@ fun MonthScreen(
|
|||||||
viewModel.goToPrev()
|
viewModel.goToPrev()
|
||||||
}
|
}
|
||||||
// Slide toward today: viewing the future → today comes in from the left
|
// 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 = {
|
val jumpToToday = {
|
||||||
slideDir = when (val s = state) {
|
if (continuous) {
|
||||||
is MonthUiState.Success ->
|
scope.launch { listState.animateScrollToItem(weekIndexOf(today, weekStart)) }
|
||||||
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
|
Unit
|
||||||
else -> 0
|
} else {
|
||||||
|
slideDir = when (val s = state) {
|
||||||
|
is MonthUiState.Success ->
|
||||||
|
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
viewModel.goToToday()
|
||||||
}
|
}
|
||||||
viewModel.goToToday()
|
|
||||||
}
|
}
|
||||||
// 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 ->
|
||||||
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
|
if (continuous) {
|
||||||
viewModel.goToDate(target)
|
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(
|
ModalNavigationDrawer(
|
||||||
@@ -174,7 +236,7 @@ fun MonthScreen(
|
|||||||
drawerContent = {
|
drawerContent = {
|
||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = LocalDate(month.year, month.month, 1),
|
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -195,7 +257,7 @@ fun MonthScreen(
|
|||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
month = month,
|
month = titleMonth,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
@@ -213,11 +275,9 @@ fun MonthScreen(
|
|||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = {
|
onCreate = {
|
||||||
// Anchor on today when its month is shown, else the 1st.
|
// Anchor on today when its month is shown, else the 1st.
|
||||||
val today = Clock.System.now()
|
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
|
||||||
onCreateEvent(
|
onCreateEvent(
|
||||||
if (isOnCurrentMonth) today
|
if (isOnCurrentMonth) today
|
||||||
else LocalDate(month.year, month.month, 1),
|
else LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -231,15 +291,25 @@ fun MonthScreen(
|
|||||||
) {
|
) {
|
||||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
MonthContent(
|
if (continuous) {
|
||||||
state = state,
|
ContinuousMonthContent(
|
||||||
slideDir = slideDir,
|
state = continuousState,
|
||||||
showWeekNumbers = showWeekNumbers,
|
listState = listState,
|
||||||
onSwipeNext = goNext,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onSwipePrev = goPrev,
|
onRetry = jumpToToday,
|
||||||
onRetry = jumpToToday,
|
onOpenDay = onOpenDay,
|
||||||
onOpenDay = onOpenDay,
|
)
|
||||||
)
|
} else {
|
||||||
|
MonthContent(
|
||||||
|
state = state,
|
||||||
|
slideDir = slideDir,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
|
onSwipeNext = goNext,
|
||||||
|
onSwipePrev = goPrev,
|
||||||
|
onRetry = jumpToToday,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthTopBar(
|
private fun MonthTopBar(
|
||||||
@@ -391,6 +487,14 @@ private val CELL_GAP = 2.dp
|
|||||||
private val CELL_SHAPE = RoundedCornerShape(12.dp)
|
private val CELL_SHAPE = RoundedCornerShape(12.dp)
|
||||||
private const val MAX_EVENT_ROWS = 3
|
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
|
@Composable
|
||||||
private fun MonthGrid(
|
private fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
@@ -406,11 +510,12 @@ private fun MonthGrid(
|
|||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
|
val month = state.month
|
||||||
state.weeks.forEach { week ->
|
state.weeks.forEach { week ->
|
||||||
MonthWeekRow(
|
MonthWeekRow(
|
||||||
week = week,
|
week = week,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
month = state.month,
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
modifier = Modifier
|
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
|
* 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
|
* a multi-day event is one connected bar across the columns; single-day timed
|
||||||
@@ -432,10 +609,11 @@ private fun MonthGrid(
|
|||||||
private fun MonthWeekRow(
|
private fun MonthWeekRow(
|
||||||
week: MonthWeek,
|
week: MonthWeek,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
month: YearMonth,
|
inMonth: (LocalDate) -> Boolean,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
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
|
||||||
@@ -466,14 +644,13 @@ private fun MonthWeekRow(
|
|||||||
// as one continuous event.
|
// as one continuous event.
|
||||||
Row(Modifier.matchParentSize()) {
|
Row(Modifier.matchParentSize()) {
|
||||||
week.days.forEach { d ->
|
week.days.forEach { d ->
|
||||||
val inMonth = d.month == month.month && d.year == month.year
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
.background(
|
.background(
|
||||||
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
|
color = if (inMonth(d)) MaterialTheme.colorScheme.surfaceContainer
|
||||||
else MaterialTheme.colorScheme.surfaceContainerLow,
|
else MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
shape = CELL_SHAPE,
|
shape = CELL_SHAPE,
|
||||||
),
|
),
|
||||||
@@ -487,7 +664,15 @@ private fun MonthWeekRow(
|
|||||||
DayNumberCell(
|
DayNumberCell(
|
||||||
date = d,
|
date = d,
|
||||||
isToday = d == today,
|
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),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -620,6 +805,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),
|
||||||
@@ -639,6 +825,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(),
|
||||||
@@ -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. */
|
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthBar(
|
private fun MonthBar(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
|
||||||
@@ -37,6 +38,23 @@ data class MonthWeek(
|
|||||||
val countByDay: Map<LocalDate, Int>,
|
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 {
|
sealed interface MonthUiState {
|
||||||
data object Loading : MonthUiState
|
data object Loading : MonthUiState
|
||||||
data class Failure(val reason: FailureReason) : MonthUiState
|
data class Failure(val reason: FailureReason) : MonthUiState
|
||||||
|
|||||||
@@ -29,6 +29,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
|
||||||
@@ -66,6 +67,14 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
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
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
@@ -91,6 +100,73 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = MonthUiState.Loading,
|
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() {
|
fun goToPrev() {
|
||||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||||
}
|
}
|
||||||
@@ -225,6 +301,52 @@ internal fun monthGridRange(
|
|||||||
return start..end
|
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 {
|
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
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