Add a multi-day view with a configurable day count (#193)

This commit is contained in:
2026-09-30 12:44:22 +02:00
parent d7315caa20
commit e8ed4c30e1
18 changed files with 454 additions and 132 deletions
@@ -32,6 +32,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -188,6 +189,7 @@ class MainActivity : AppCompatActivity() {
LocalUse24HourFormat provides use24Hour,
LocalShowHourGrid provides settings.showHourGrid,
LocalTimelineZoom provides timelineZoom,
LocalMultiDayCount provides settings.multiDayCount,
LocalSoftenColors provides settings.softenColors,
) {
RootScreen(
@@ -287,6 +287,15 @@ class SettingsPrefs @Inject constructor(
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
}
/** How many days the multi-day view shows side by side (#193). */
val multiDayCount: Flow<Int> = store.data.map { prefs ->
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
}
suspend fun setMultiDayCount(count: Int) {
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
}
/**
* Where the jump-to-today control lives (issue #60). Default OFF — the
* historical layout, where it's an extended FAB that fades in above the "+"
@@ -948,10 +957,20 @@ class SettingsPrefs @Inject constructor(
if (view in config.enabled) view.name else "!${view.name}"
}
/** Keep the given order (de-duplicated), then append any views it omits. */
/** Keep stored order; insert the new multi-day view after Week on upgrades. */
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
val ordered = seen.distinct()
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
val ordered = seen.distinct().toMutableList()
IMPLEMENTED_VIEWS.forEach { view ->
if (view !in ordered) {
val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) {
ordered.indexOf(CalendarView.Week) + 1
} else {
ordered.size
}
ordered.add(index, view)
}
}
return ordered
}
companion object {
@@ -1017,8 +1036,11 @@ class SettingsPrefs @Inject constructor(
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
private const val MINUTES_PER_DAY = 1_440
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count")
/** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10
const val DEFAULT_MULTI_DAY_COUNT = 3
val MULTI_DAY_COUNTS = 3..6
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_reminder_override")
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
@@ -45,6 +45,7 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.MultiDayScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -380,6 +381,18 @@ fun CalendarHost(
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.MultiDay -> MultiDayScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Day -> DayScreen(
selectedView = currentView,
onSelectView = onSelectView,
@@ -91,7 +91,7 @@ fun CalendarDrawer(
DrawerSectionHeader(stringResource(R.string.view_section))
viewOrder.forEachIndexed { index, view ->
GroupedRow(
title = stringResource(view.labelRes),
title = view.label(),
position = positionOf(index, viewOrder.size),
selected = view == currentView,
minHeight = 56.dp,
@@ -1,37 +1,53 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarViewDay
import androidx.compose.material.icons.filled.CalendarViewMonth
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.ViewAgenda
import androidx.compose.material.icons.filled.ViewColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
/** The top-level calendar views the user can switch between (spec M1). */
enum class CalendarView {
Month,
Week,
MultiDay,
Day,
Agenda,
}
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
@get:StringRes
val CalendarView.labelRes: Int
get() = when (this) {
CalendarView.Month -> R.string.view_month
CalendarView.Week -> R.string.view_week
CalendarView.Day -> R.string.view_day
CalendarView.Agenda -> R.string.view_agenda
/** How many days the multi-day view shows (#193), provided once at the app root. */
val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT }
/**
* The view's name, shared by the top-bar pill, the drawer and Settings. The
* multi-day view is named after its day count ("3 days").
*/
@Composable
fun CalendarView.label(): String = when (this) {
CalendarView.Month -> stringResource(R.string.view_month)
CalendarView.Week -> stringResource(R.string.view_week)
CalendarView.MultiDay -> {
val count = LocalMultiDayCount.current
pluralStringResource(R.plurals.view_multi_day, count, count)
}
CalendarView.Day -> stringResource(R.string.view_day)
CalendarView.Agenda -> stringResource(R.string.view_agenda)
}
/** Leading icon for the view in the drawer's View section. */
val CalendarView.icon: ImageVector
get() = when (this) {
CalendarView.Month -> Icons.Filled.CalendarViewMonth
CalendarView.Week -> Icons.Filled.CalendarViewWeek
CalendarView.MultiDay -> Icons.Filled.ViewColumn
CalendarView.Day -> Icons.Filled.CalendarViewDay
CalendarView.Agenda -> Icons.Filled.ViewAgenda
}
@@ -41,7 +57,13 @@ val CalendarView.icon: ImageVector
* through these in order.
*/
val IMPLEMENTED_VIEWS: List<CalendarView> =
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
listOf(
CalendarView.Month,
CalendarView.Week,
CalendarView.MultiDay,
CalendarView.Day,
CalendarView.Agenda,
)
/** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
@@ -35,6 +35,6 @@ fun ViewSwitcherPill(
shape = MaterialTheme.shapes.large,
modifier = modifier.padding(end = trailingInset),
) {
Text(stringResource(current.labelRes))
Text(current.label())
}
}
@@ -18,10 +18,11 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.label
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
@@ -67,6 +68,11 @@ internal fun ViewStep(
height = PREVIEW_HEIGHT,
)
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
CalendarView.MultiDay -> WeekViewPreview(
weekStart = weekStart,
height = PREVIEW_HEIGHT,
dayCount = LocalMultiDayCount.current,
)
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
}
@@ -74,7 +80,7 @@ internal fun ViewStep(
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
val isSelected = view == choice.defaultView
GroupedRow(
title = stringResource(view.labelRes),
title = view.label(),
position = positionOf(index, IMPLEMENTED_VIEWS.size),
selected = isSelected,
leading = {
@@ -34,6 +34,8 @@ data class SettingsUiState(
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
val showHourGrid: Boolean = false,
/** Days the multi-day view shows side by side (#193). */
val multiDayCount: Int = SettingsPrefs.DEFAULT_MULTI_DAY_COUNT,
/** How the Agenda screen treats events that already ended today. */
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */
@@ -148,11 +148,12 @@ class SettingsViewModel @Inject constructor(
combine(
prefs.todayButtonInToolbar,
prefs.dragToReschedule,
::Pair,
prefs.multiDayCount,
::Triple,
),
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
DisplayToggles(
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
)
},
) { view, screenRange, widgetRange, timeFormat, toggles ->
@@ -164,6 +165,7 @@ class SettingsViewModel @Inject constructor(
softenColors = toggles.softenColors,
todayButtonInToolbar = toggles.todayButtonInToolbar,
dragToReschedule = toggles.dragToReschedule,
multiDayCount = toggles.multiDayCount,
)
},
combine(
@@ -197,6 +199,7 @@ class SettingsViewModel @Inject constructor(
softenColors = views.softenColors,
todayButtonInToolbar = views.todayButtonInToolbar,
dragToReschedule = views.dragToReschedule,
multiDayCount = views.multiDayCount,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
@@ -298,6 +301,7 @@ class SettingsViewModel @Inject constructor(
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
val multiDayCount: Int,
)
private data class DisplayToggles(
@@ -307,6 +311,7 @@ class SettingsViewModel @Inject constructor(
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
val multiDayCount: Int,
)
private data class MiscSettings(
@@ -532,6 +537,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTimeFormat(pref) }
}
fun setMultiDayCount(count: Int) {
viewModelScope.launch { prefs.setMultiDayCount(count) }
}
fun setShowHourGrid(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
}
@@ -21,10 +21,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
@@ -37,6 +39,7 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.label
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.floret.components.CollapsingScaffold
@@ -70,6 +73,7 @@ internal fun ViewsScreen(
var showPastEvents by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showTimelineScale by remember { mutableStateOf(false) }
var showMultiDayCount by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_views),
@@ -82,7 +86,7 @@ internal fun ViewsScreen(
SectionHeader(stringResource(R.string.settings_views_all_header))
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
summary = state.defaultView.label(),
position = Position.Top,
onClick = { showDefaultView = true },
)
@@ -158,10 +162,16 @@ internal fun ViewsScreen(
Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_week_day_header))
GroupedRow(
title = stringResource(R.string.settings_multi_day_count),
summary = multiDayLabel(state.multiDayCount),
position = Position.Top,
onClick = { showMultiDayCount = true },
)
GroupedRow(
title = stringResource(R.string.settings_timeline_scale),
summary = stringResource(state.timelineScale.labelRes),
position = Position.Top,
position = Position.Middle,
onClick = { showTimelineScale = true },
)
GroupedRow(
@@ -275,7 +285,7 @@ internal fun ViewsScreen(
predictiveBack = true,
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
label = { it.label() },
leading = {
Icon(
imageVector = it.icon,
@@ -337,6 +347,18 @@ internal fun ViewsScreen(
onDismiss = { showPastEvents = false },
)
}
if (showMultiDayCount) {
OptionPicker(
title = stringResource(R.string.settings_multi_day_count),
header = { PickerDescription(stringResource(R.string.settings_multi_day_count_hint)) },
predictiveBack = true,
options = SettingsPrefs.MULTI_DAY_COUNTS.toList(),
selected = state.multiDayCount,
label = { multiDayLabel(it) },
onSelect = viewModel::setMultiDayCount,
onDismiss = { showMultiDayCount = false },
)
}
if (showTimelineScale) {
OptionPicker(
title = stringResource(R.string.settings_timeline_scale),
@@ -384,7 +406,7 @@ private fun ViewRow(
trailing: @Composable (() -> Unit)? = null,
) {
GroupedRow(
title = stringResource(view.labelRes),
title = view.label(),
position = position,
dimmed = dimmed,
minHeight = ReorderableRowHeight,
@@ -449,3 +471,7 @@ internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode)
PastEventDisplay.DIM -> R.string.settings_past_events_dim
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
}
@Composable
private fun multiDayLabel(count: Int): String =
pluralStringResource(R.plurals.view_multi_day, count, count)
@@ -23,3 +23,53 @@ internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
}
/** How a timeline pager cuts the calendar into pages of [dayCount] days. */
sealed interface PageGrid {
val dayCount: Int
val pageCount: Int
/** First day shown on pager [page]. */
fun startForPage(page: Int): LocalDate
/** The pager page showing [date]. */
fun pageFor(date: LocalDate): Int
}
/** Whole weeks, starting on [firstDay]. */
data class WeekGrid(val firstDay: DayOfWeek) : PageGrid {
override val dayCount: Int get() = 7
override val pageCount: Int get() = WEEK_PAGE_COUNT
override fun startForPage(page: Int): LocalDate = weekStartForPage(page, firstDay)
override fun pageFor(date: LocalDate): Int = weekPageFor(date, firstDay)
}
/** Pages the multi-day pager spans — over three centuries at the widest page. */
internal const val DAYS_PAGE_COUNT: Int = 40_000
private const val DAYS_EPOCH_PAGE = DAYS_PAGE_COUNT / 2
/** A fixed day the multi-day pages are counted from. */
private val DAYS_EPOCH = LocalDate(2000, 1, 1)
/**
* Runs of [dayCount] days (#193), offset [phase] days from a fixed epoch so any
* date can start a page. The phase stays put while paging and only changes when
* a jump moves another date to the first column.
*/
data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
override val pageCount: Int get() = DAYS_PAGE_COUNT
override fun startForPage(page: Int): LocalDate =
DAYS_EPOCH.plus(phase + (page - DAYS_EPOCH_PAGE) * dayCount, DateTimeUnit.DAY)
override fun pageFor(date: LocalDate): Int =
(DAYS_EPOCH_PAGE + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
.coerceIn(0, DAYS_PAGE_COUNT - 1)
companion object {
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */
fun startingAt(date: LocalDate, dayCount: Int): DaysGrid =
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
}
}
@@ -15,7 +15,6 @@ import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.datetime.DayOfWeek
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -186,7 +185,6 @@ internal fun WeekUiState.Success.allDayStripHeight(): Dp {
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeekScreen(
selectedView: CalendarView,
@@ -201,10 +199,75 @@ fun WeekScreen(
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) = PagedTimelineScreen(
viewModel = viewModel,
showWeekNumber = true,
selectedView = selectedView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
modifier = modifier,
)
/** The multi-day view (#193): the week view's timeline over the day count from Settings. */
@Composable
fun MultiDayScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
viewModel: MultiDayViewModel = hiltViewModel(),
) = PagedTimelineScreen(
viewModel = viewModel,
// A run of days often straddles two weeks, so no single number fits it.
showWeekNumber = false,
selectedView = selectedView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
modifier = modifier,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PagedTimelineScreen(
viewModel: PagedTimelineViewModel,
showWeekNumber: Boolean,
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView>,
drawerViewOrder: List<CalendarView>,
todayInToolbar: Boolean,
modifier: Modifier,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val firstDay by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
val position by viewModel.position.collectAsStateWithLifecycle()
val grid = position.grid
val anchorPage = position.page
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
@@ -217,39 +280,47 @@ fun WeekScreen(
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = anchorPage) { WEEK_PAGE_COUNT }
// A new grid numbers the pages differently, so it gets a pager of its own,
// opened straight on the anchor's page: the first day of the week changing,
// or a multi-day jump moving another date into the first column.
val pagerState = key(grid) {
rememberPagerState(initialPage = anchorPage) { grid.pageCount }
}
val pageSpec = rememberCalendarPageSpec()
val reduceMotion = rememberReduceMotion()
// The pager leads and the anchor follows once it settles, so the only thing
// left moving the anchor on its own is the first day re-framing the week —
// including the stored preference arriving after the pager opened on the
// Monday default. Snapped, since that is a correction rather than a move.
LaunchedEffect(anchorPage) {
// The pager leads and the anchor follows once it settles, so this only
// catches the anchor moving on its own. Snapped, since that is a correction
// rather than a move.
LaunchedEffect(pagerState, anchorPage) {
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
pagerState.scrollToPage(anchorPage)
}
}
// The page it opened on is skipped: a first-day change landing in between
// would read it as a different week.
// The page it opened on is skipped: a grid change landing in between would
// read it as a different page.
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
}
// The week under the finger, so the title turns over mid-swipe, not after it.
val weekStart = weekStartForPage(pagerState.currentPage, firstDay)
// The page under the finger, so the title turns over mid-swipe, not after it.
val weekStart = grid.startForPage(pagerState.currentPage)
val weekEnd = weekStart.plus(grid.dayCount - 1, kotlinx.datetime.DateTimeUnit.DAY)
// Off the ticking clock rather than the loaded week, so it turns over at
// Off the ticking clock rather than the loaded page, so it turns over at
// midnight; derived, so the per-minute tick doesn't recompose the screen.
val zone = remember { TimeZone.currentSystemDefault() }
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
// Independent of which weekday the user picked as the first day.
val isOnCurrentWeek = state !is WeekUiState.Success ||
(today >= weekStart && today <= weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY))
val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd)
// Straight to the pager: a tap mid-fling must still land, and the anchor
// hasn't caught up with the fling yet to tell the difference.
// hasn't caught up with the fling yet to tell the difference. A date that
// would open mid-page on this grid re-frames the pages around it instead.
val jumpToDate: (LocalDate) -> Unit = { target ->
val page = weekPageFor(target, firstDay)
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
val page = grid.pageFor(target)
if (grid is DaysGrid && grid.startForPage(page) != target) {
viewModel.jumpTo(target)
} else {
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
}
}
val jumpToToday = { jumpToDate(today) }
@@ -283,6 +354,7 @@ fun WeekScreen(
topBar = {
WeekTopBar(
weekStart = weekStart,
weekEnd = weekEnd,
currentYear = today.year,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
@@ -310,8 +382,9 @@ fun WeekScreen(
WeekContent(
state = state,
pagerState = pagerState,
firstDay = firstDay,
week = viewModel::week,
grid = grid,
showWeekNumber = showWeekNumber,
span = viewModel::span,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
@@ -329,8 +402,9 @@ fun WeekScreen(
private fun WeekContent(
state: WeekUiState,
pagerState: PagerState,
firstDay: DayOfWeek,
week: (LocalDate) -> StateFlow<WeekUiState>,
grid: PageGrid,
showWeekNumber: Boolean,
span: (DaySpan) -> StateFlow<WeekUiState>,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
@@ -378,12 +452,13 @@ private fun WeekContent(
label = "week-state",
) { s ->
when (s) {
WeekUiState.Loading -> WeekLoading()
WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount)
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekPager(
pagerState = pagerState,
firstDay = firstDay,
week = week,
grid = grid,
showWeekNumber = showWeekNumber,
span = span,
today = s.today,
initialStripHeight = s.allDayStripHeight(),
scrollState = scrollState,
@@ -406,8 +481,9 @@ private fun WeekContent(
@Composable
private fun WeekPager(
pagerState: PagerState,
firstDay: DayOfWeek,
week: (LocalDate) -> StateFlow<WeekUiState>,
grid: PageGrid,
showWeekNumber: Boolean,
span: (DaySpan) -> StateFlow<WeekUiState>,
today: LocalDate,
initialStripHeight: Dp,
scrollState: ScrollState,
@@ -420,7 +496,8 @@ private fun WeekPager(
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
WeekFrame(
weekStart = weekStartForPage(pagerState.currentPage, firstDay),
weekStart = grid.startForPage(pagerState.currentPage),
showWeekNumber = showWeekNumber,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
@@ -438,11 +515,11 @@ private fun WeekPager(
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { page ->
val start = weekStartForPage(page, firstDay)
val pageState by remember(start) { week(start) }.collectAsStateWithLifecycle()
val pageSpan = DaySpan(grid.startForPage(page), grid.dayCount)
val pageState by remember(pageSpan) { span(pageSpan) }.collectAsStateWithLifecycle()
val loaded = pageState as? WeekUiState.Success
// Until its week arrives a page shows the week's frame without events.
val empty = remember(start, today) { emptyWeek(start, today) }
// Until its days arrive a page shows their frame without events.
val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) }
val stripHeight = loaded?.allDayStripHeight()
DisposableEffect(page, stripHeight) {
if (stripHeight != null) stripHeights[page] = stripHeight
@@ -463,11 +540,11 @@ private fun WeekPager(
}
}
/** A week with no events yet, for a page whose week is still loading. */
private fun emptyWeek(start: LocalDate, today: LocalDate): WeekUiState.Success {
val days = (0 until 7).map { start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
/** A page with no events yet, for one whose days are still loading. */
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
return WeekUiState.Success(
weekStart = start,
weekStart = span.start,
today = today,
days = days,
allDaySpans = emptyList(),
@@ -482,6 +559,7 @@ private fun emptyWeek(start: LocalDate, today: LocalDate): WeekUiState.Success {
@Composable
private fun WeekFrame(
weekStart: LocalDate,
showWeekNumber: Boolean,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
@@ -494,7 +572,7 @@ private fun WeekFrame(
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
) {
WeekNumberCell(weekStart)
WeekNumberCell(weekStart, showBadge = showWeekNumber)
Spacer(Modifier.height(allDayHeight))
}
Spacer(Modifier.height(TIMELINE_TOP_GAP))
@@ -519,6 +597,7 @@ private fun WeekFrame(
@Composable
internal fun WeekSuccess(
state: WeekUiState.Success,
showWeekNumber: Boolean = true,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
@@ -529,6 +608,7 @@ internal fun WeekSuccess(
) {
WeekFrame(
weekStart = state.weekStart,
showWeekNumber = showWeekNumber,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
@@ -597,6 +677,7 @@ private fun WeekPage(
@Composable
private fun WeekTopBar(
weekStart: LocalDate,
weekEnd: LocalDate,
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
@@ -608,9 +689,9 @@ private fun WeekTopBar(
onToday: () -> Unit,
) {
val locale = currentLocale()
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
formatWeekTitle(weekStart, locale, currentYear) to
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
val (title, shortTitle) = remember(weekStart, weekEnd, locale, currentYear) {
formatWeekTitle(weekStart, weekEnd, locale, currentYear) to
formatWeekTitle(weekStart, weekEnd, locale, currentYear, abbreviated = true)
}
TopAppBar(
title = {
@@ -724,7 +805,7 @@ private fun WeekDayHeader(
* bar's hamburger (see GUTTER_CONTENT_START_INSET).
*/
@Composable
private fun WeekNumberCell(weekStart: LocalDate) {
private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean) {
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
Column(
modifier = Modifier
@@ -739,7 +820,7 @@ private fun WeekNumberCell(weekStart: LocalDate) {
Text(text = " ", style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.height(2.dp))
Box(modifier = Modifier.height(DATE_SLOT_SIZE), contentAlignment = Alignment.TopCenter) {
WeekNumberBadge(weekNumber = weekNumber)
if (showBadge) WeekNumberBadge(weekNumber = weekNumber)
}
}
}
@@ -793,7 +874,7 @@ private fun AllDayStrip(
.fillMaxHeight()
.clipToBounds(),
) {
val colWidth = maxWidth / 7
val colWidth = maxWidth / state.days.size
state.allDaySpans.forEach { span ->
val spanCols = span.endCol - span.startCol + 1
AllDayBar(
@@ -1177,14 +1258,14 @@ private fun EventBlock(
}
@Composable
private fun WeekLoading() {
private fun WeekLoading(dayCount: Int) {
val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState()
Column(modifier = Modifier.fillMaxSize()) {
// Header skeleton
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Spacer(Modifier.width(GUTTER_WIDTH))
repeat(7) {
repeat(dayCount) {
Box(
modifier = Modifier
.weight(1f)
@@ -1215,7 +1296,7 @@ private fun WeekLoading() {
.verticalScroll(scrollState),
) {
Spacer(Modifier.width(GUTTER_WIDTH))
repeat(7) {
repeat(dayCount) {
Box(
modifier = Modifier
.weight(1f)
@@ -1255,11 +1336,11 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
*/
private fun formatWeekTitle(
weekStart: LocalDate,
weekEnd: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String {
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
return formatCalendarTitle(
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
locale = locale,
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -45,19 +46,34 @@ import kotlin.math.abs
const val MINUTES_PER_DAY: Int = 24 * 60
/** How far from the requested week [WeekViewModel.week] keeps other weeks cached. */
private const val WEEK_CACHE_DAYS = 28
/** How far from the requested page [PagedTimelineViewModel.span] keeps other pages cached. */
private const val SPAN_CACHE_DAYS = 28
/** A run of [count] days from [start]: what one timeline page shows. */
data class DaySpan(val start: LocalDate, val count: Int)
/** How the pager cuts the calendar, and the page the anchor sits on. */
data class PagerPosition(val grid: PageGrid, val page: Int)
/**
* A timeline paged in runs of days: the week view, and the multi-day view (#193).
* The pager leads; the anchor follows it once it settles, and the grid it is
* cut by is derived from the anchor through [gridRule], so a subclass decides
* whether a jump re-frames the pages or only moves between them.
*
* @param gridRule the grid to use for a given anchor, as the settings behind it change.
* @param initialRule the rule used until [gridRule] first emits.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class WeekViewModel @Inject constructor(
abstract class PagedTimelineViewModel(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
private val io: CoroutineDispatcher,
gridRule: Flow<(LocalDate) -> PageGrid>,
initialRule: (LocalDate) -> PageGrid,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
private val locale: Locale = Locale.getDefault()
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
@@ -70,38 +86,28 @@ class WeekViewModel @Inject constructor(
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
/** First day of the week, from the Settings preference (AUTO → locale). */
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.weekStart
.map { it.resolveFirstDay(locale) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayOfWeek.MONDAY,
)
// Anchor is a representative day inside the visible week; the actual week
// start is derived against [firstDayOfWeek], so changing the first-day
// preference re-frames the same week instead of jumping.
// A day on the visible page; the page itself comes from the grid, so a
// settings change re-frames the same days instead of jumping.
private val _anchor = MutableStateFlow(todayDate)
/** The pager page the anchor's week sits on. */
val anchorPage: StateFlow<Int> =
combine(_anchor, firstDayOfWeek) { anchor, ws -> weekPageFor(anchor, ws) }
val position: StateFlow<PagerPosition> =
combine(_anchor, gridRule) { anchor, rule -> rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) } }
.distinctUntilChanged()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = weekPageFor(todayDate, DayOfWeek.MONDAY),
initialValue = initialRule(todayDate).let { PagerPosition(it, it.pageFor(todayDate)) },
)
/**
* The anchor week's state. Once loaded it never falls back to [WeekUiState.Loading]:
* moving the anchor keeps the last result until the new week arrives, since
* the pages draw their own weeks and this only gates the failure screen.
* The anchor page's state. Once loaded it never falls back to [WeekUiState.Loading]:
* moving the anchor keeps the last result until the new page arrives, since
* the pages draw their own days and this only gates the failure screen.
*/
val state: StateFlow<WeekUiState> =
combine(_anchor, firstDayOfWeek) { anchor, ws -> anchor.startOfWeek(ws) }
position.map { DaySpan(it.grid.startForPage(it.page), it.grid.dayCount) }
.distinctUntilChanged()
.flatMapLatest { start -> week(start).filterNot { it is WeekUiState.Loading } }
.flatMapLatest { span -> span(span).filterNot { it is WeekUiState.Loading } }
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.stateIn(
scope = viewModelScope,
@@ -109,47 +115,53 @@ class WeekViewModel @Inject constructor(
initialValue = WeekUiState.Loading,
)
private val weeks = PageStateCache<LocalDate, WeekUiState>(
private val spans = PageStateCache<DaySpan, WeekUiState>(
scope = viewModelScope,
initial = WeekUiState.Loading,
// Far enough to cover the pages either side and a swipe back, so paging
// through a year doesn't keep a year of queries around.
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= WEEK_CACHE_DAYS },
) { start ->
keep = { cached, requested ->
cached.count == requested.count &&
abs(cached.start.daysUntil(requested.start)) <= SPAN_CACHE_DAYS
},
) { span ->
combine(
repository.calendars(),
repository.instances(weekRange(start, zone)),
repository.instances(spanRange(span.start, span.count, zone)),
) { calendars, instances ->
buildState(start, calendars, instances)
buildState(span, calendars, instances)
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
}
/** The week starting at [start], shared between its pager page and [state]. Main thread only. */
fun week(start: LocalDate): StateFlow<WeekUiState> = weeks.get(start)
/** The days of one page, shared between that page and [state]. Main thread only. */
fun span(span: DaySpan): StateFlow<WeekUiState> = spans.get(span)
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
val ws = firstDayOfWeek.value
if (weekPageFor(_anchor.value, ws) != page) _anchor.value = weekStartForPage(page, ws)
val grid = position.value.grid
if (grid.pageFor(_anchor.value) != page) _anchor.value = grid.startForPage(page)
}
fun goToToday() {
_anchor.value = todayDate
/** Move the anchor to [date], re-framing the pages if the grid depends on it. */
fun jumpTo(date: LocalDate) {
_anchor.value = date
}
fun goToToday() = jumpTo(todayDate)
private fun buildState(
start: LocalDate,
span: DaySpan,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return WeekUiState.Success(
weekStart = start,
weekStart = span.start,
today = todayDate,
days = days,
allDaySpans = layoutAllDay(allDay, days, zone),
@@ -158,6 +170,44 @@ class WeekViewModel @Inject constructor(
}
}
/** Whole weeks, starting on the first day from Settings (AUTO → locale). */
@HiltViewModel
class WeekViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
io = io,
gridRule = settingsPrefs.weekStart.map { pref ->
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
val rule: (LocalDate) -> PageGrid = { grid }
rule
},
initialRule = { WeekGrid(DayOfWeek.MONDAY) },
)
/**
* The multi-day view (#193): runs of the day count from Settings, with the
* anchor always a page's first day, so today — or a date jumped to — opens in
* the first column.
*/
@HiltViewModel
class MultiDayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
io = io,
gridRule = settingsPrefs.multiDayCount.map { count ->
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
},
initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_COUNT) },
)
/**
* Lay out all-day events as connected horizontal spans across the visible week.
* Each event becomes one [AllDaySpan] from its first to its last covered column;
@@ -211,10 +261,10 @@ internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
return minus(offset, DateTimeUnit.DAY)
}
/** Half-open instant range covering the seven days starting at [start]. */
internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
/** Instant range covering the [count] days starting at [start]. */
internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange<Instant> {
val from = start.atStartOfDayIn(zone)
val to = start.plus(6, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
val to = start.plus(count - 1, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to
}
@@ -32,10 +32,13 @@ internal fun WeekViewPreview(
weekStart: DayOfWeek,
height: Dp,
modifier: Modifier = Modifier,
dayCount: Int = 7,
) {
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val state = remember(today, weekStart, zone) { sampleWeekState(today, weekStart, zone) }
val state = remember(today, weekStart, dayCount, zone) {
sampleWeekState(today, weekStart, dayCount, zone)
}
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
@@ -46,6 +49,7 @@ internal fun WeekViewPreview(
ScaledViewPreview(height = height, modifier = modifier) {
WeekSuccess(
state = state,
showWeekNumber = dayCount == 7,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
@@ -61,10 +65,11 @@ internal fun WeekViewPreview(
private fun sampleWeekState(
today: LocalDate,
weekStart: DayOfWeek,
dayCount: Int,
zone: TimeZone,
): WeekUiState.Success {
val start = today.startOfWeek(weekStart)
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
val start = if (dayCount == 7) today.startOfWeek(weekStart) else today
val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) }
val events = sampleTimelineEvents(days, today, zone)
return WeekUiState.Success(
weekStart = start,
+7
View File
@@ -355,6 +355,11 @@
<!-- View switcher (M1) -->
<string name="view_month">Month</string>
<string name="view_week">Week</string>
<!-- The multi-day view, named after how many days it shows (#193) -->
<plurals name="view_multi_day">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
</plurals>
<string name="view_day">Day</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">View</string>
@@ -470,6 +475,8 @@
each hour") describes a feature that no longer exists and needs redoing. -->
<string name="settings_hour_lines">Hour grid</string>
<string name="settings_hour_lines_summary">Seat each hour in its own cell in week and day view</string>
<string name="settings_multi_day_count">Multi-day view</string>
<string name="settings_multi_day_count_hint">How many days the multi-day view shows side by side. Today opens in the first column.</string>
<string name="settings_timeline_scale">Hour height</string>
<string name="settings_timeline_scale_hint">How much vertical space one hour takes in week and day view. Both views share this setting. You can also pinch the timeline with two fingers to set any height in between.</string>
<string name="timeline_scale_fit_day">Fit whole day</string>
@@ -235,7 +235,8 @@ class SettingsPrefsTest {
prefs.setQuickSwitchConfig(config)
val loaded = prefs.quickSwitchConfig.first()
assertThat(loaded.order).containsExactly(
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day,
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
CalendarView.MultiDay, CalendarView.Day,
).inOrder()
assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month)
assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
@@ -252,13 +253,14 @@ class SettingsPrefsTest {
m
}
val config = prefs.quickSwitchConfig.first()
// Stored order first (Day, Week), then the omitted views in default order.
// Stored order first, with the new view after Week.
assertThat(config.order).containsExactly(
CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
CalendarView.Day, CalendarView.Week, CalendarView.MultiDay,
CalendarView.Month, CalendarView.Agenda,
).inOrder()
// Day was explicitly disabled; the appended Month & Agenda default enabled.
// Day was explicitly disabled; the missing views default enabled.
assertThat(config.enabled).containsExactly(
CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
CalendarView.Week, CalendarView.MultiDay, CalendarView.Month, CalendarView.Agenda,
)
}
@@ -272,9 +274,12 @@ class SettingsPrefsTest {
val newOrder = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day)
prefs.updateQuickSwitch { it.copy(order = newOrder) }
val config = prefs.quickSwitchConfig.first()
assertThat(config.order).containsExactlyElementsIn(newOrder).inOrder()
assertThat(config.enabled).containsExactly(
assertThat(config.order).containsExactly(
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
CalendarView.MultiDay, CalendarView.Day,
).inOrder()
assertThat(config.enabled).containsExactly(
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.MultiDay,
)
}
@@ -286,7 +291,8 @@ class SettingsPrefsTest {
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
)
assertThat(prefs.drawerViewOrder.first()).containsExactly(
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
CalendarView.Agenda, CalendarView.Day, CalendarView.Week,
CalendarView.MultiDay, CalendarView.Month,
).inOrder()
}
@@ -300,7 +306,8 @@ class SettingsPrefsTest {
m
}
assertThat(prefs.drawerViewOrder.first()).containsExactly(
CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day,
CalendarView.Agenda, CalendarView.Week, CalendarView.MultiDay,
CalendarView.Month, CalendarView.Day,
).inOrder()
}
@@ -49,8 +49,8 @@ class WeekLayoutTest {
}
@Test
fun `weekRange spans seven days`() {
val range = weekRange(mon, zone)
fun `spanRange spans seven days`() {
val range = spanRange(mon, 7, zone)
assertThat(range.start).isEqualTo(at(mon, 0, 0))
// endInclusive is the last second of day 7 (Sunday 2026-06-14)
assertThat(range.endInclusive).isEqualTo(LocalDate(2026, 6, 14).atTime(23, 59, 59).toInstant(zone))
@@ -44,4 +44,24 @@ class WeekPagingTest {
val page = weekPageFor(old, DayOfWeek.MONDAY)
assertThat(weekStartForPage(page, DayOfWeek.MONDAY)).isEqualTo(LocalDate(1990, 5, 14))
}
@Test
fun `multi-day pages begin on the selected date and advance by the chosen count`() {
(3..6).forEach { count ->
val grid = DaysGrid.startingAt(sunday, count)
val page = grid.pageFor(sunday)
assertThat(grid.startForPage(page)).isEqualTo(sunday)
assertThat(grid.startForPage(page + 1)).isEqualTo(sunday.plus(count, DateTimeUnit.DAY))
assertThat(grid.pageFor(sunday.plus(count - 1, DateTimeUnit.DAY))).isEqualTo(page)
}
}
@Test
fun `multi-day pages also work before the epoch`() {
val date = LocalDate(1990, 5, 16)
val grid = DaysGrid.startingAt(date, 3)
val page = grid.pageFor(date)
assertThat(grid.startForPage(page)).isEqualTo(date)
assertThat(grid.startForPage(page - 1)).isEqualTo(LocalDate(1990, 5, 13))
}
}