diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 5de78db..8801b44 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 71eaad3..c911efa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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): List { - 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 = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index dc4d5a8..5f445ac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt index f48f841..e2a1d02 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt index 18bb606..01033d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt @@ -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 = - 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 = IMPLEMENTED_VIEWS): CalendarView { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt index 3c60627..10e17c4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt @@ -35,6 +35,6 @@ fun ViewSwitcherPill( shape = MaterialTheme.shapes.large, modifier = modifier.padding(end = trailingInset), ) { - Text(stringResource(current.labelRes)) + Text(current.label()) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt index caf14e4..8f71abc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt @@ -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 = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index d3f6f9d..2741f5a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index f6a0105..3ca82ab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index a0c988f..2f26da4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt index a3ff208..5c2d92d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -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)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index a9bb959..855d5fc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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 = IMPLEMENTED_VIEWS, + drawerViewOrder: List = 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, + drawerViewOrder: List, + 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, + grid: PageGrid, + showWeekNumber: Boolean, + span: (DaySpan) -> StateFlow, 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, + grid: PageGrid, + showWeekNumber: Boolean, + span: (DaySpan) -> StateFlow, today: LocalDate, initialStripHeight: Dp, scrollState: ScrollState, @@ -420,7 +496,8 @@ private fun WeekPager( val stripHeights = remember { mutableStateMapOf() } 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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 30c31e4..fef5385 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -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 = 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 = 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 = - combine(_anchor, firstDayOfWeek) { anchor, ws -> weekPageFor(anchor, ws) } + val position: StateFlow = + 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 = - 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( + private val spans = PageStateCache( 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 = weeks.get(start) + /** The days of one page, shared between that page and [state]. Main thread only. */ + fun span(span: DaySpan): StateFlow = 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, instances: List, ): 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 range covering the [count] days starting at [start]. */ +internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange { 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index 977ac1f..8a7feee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ef8b99..e912658 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -355,6 +355,11 @@ Month Week + + + %d day + %d days + Day Agenda View @@ -470,6 +475,8 @@ each hour") describes a feature that no longer exists and needs redoing. --> Hour grid Seat each hour in its own cell in week and day view + Multi-day view + How many days the multi-day view shows side by side. Today opens in the first column. Hour height 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. Fit whole day diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index e4b28f0..7c77993 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -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() } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt index 32c6c37..3c30d2d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -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)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt index 8514d9b..2ac6c63 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt @@ -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)) + } }