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