From d7315caa20bdd1cf3fb5c6217d13d4b9cf0f4f7a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 25 Sep 2026 14:40:04 +0200 Subject: [PATCH 1/2] Page the month view with a pager that follows the finger (#336) Paged and Split now page like the week and day views; Split keeps its vertical expand drag, now vertical-only beside the pager, and the pane follows the grid's row count mid-swipe. The old trigger swipe and slide transition are gone with their last callers. Closes #336 --- .../calendula/ui/common/CalendarPager.kt | 14 +- .../calendula/ui/common/CalendarSwipe.kt | 79 --- .../ui/common/CalendarTransitions.kt | 62 +-- .../calendula/ui/common/TimelineDrag.kt | 4 +- .../calendula/ui/day/DayScreen.kt | 4 +- .../calendula/ui/month/MonthScreen.kt | 524 +++++++++--------- .../calendula/ui/month/MonthViewModel.kt | 74 ++- .../calendula/ui/week/WeekScreen.kt | 4 +- 8 files changed, 339 insertions(+), 426 deletions(-) delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPager.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPager.kt index 4aa991e..e6baa6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPager.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPager.kt @@ -17,15 +17,17 @@ import kotlinx.coroutines.flow.collectLatest import kotlin.math.floor /** - * One all-day strip height for every page, blended between the two pages a swipe - * sits between so their timelines stay level mid-swipe. It follows the swipe - * directly and only springs when a page's own strip changes at rest. [fallback] - * stands in until the pages have reported theirs, so it doesn't open from zero. + * One height for every page of something that varies from page to page — the + * timelines' all-day strip, the split grid's rows — blended between the two + * pages a swipe sits between, so what sits below it moves with the finger rather + * than jumping on settle. It follows the swipe directly and only springs when a + * page's own height changes at rest. [fallback] stands in until the pages have + * reported theirs, so it doesn't open from zero. * - * @param heights each composed page's own strip height, by page index. + * @param heights each composed page's own height, by page index. */ @Composable -fun pagedStripHeight(pagerState: PagerState, heights: Map, fallback: Dp): Dp { +fun pagedHeight(pagerState: PagerState, heights: Map, fallback: Dp): Dp { val currentFallback by rememberUpdatedState(fallback) val target by remember(pagerState, heights) { derivedStateOf { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt deleted file mode 100644 index dc15d03..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt +++ /dev/null @@ -1,79 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.common - -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp - -/** - * Drag distance that turns a calendar page, shared by the month, week and day - * views so all three answer a swipe at the same point. - * - * It was 6dp once, which is inside the distance a tap wanders: brushing the grid - * changed the month, and a page that turns on an unintended gesture reads as the - * animation misfiring rather than as the gesture being over-eager. - */ -val CALENDAR_SWIPE_THRESHOLD = 24.dp - -/** - * The whole-page horizontal swipe: one page per gesture, committed **the moment - * the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts. - * - * Waiting for the lift meant the page sat still under a finger that had already - * travelled far enough to ask for it, and the answer only arrived once you let - * go — which reads as the view being slow rather than as a deliberate commit. - * Firing on the threshold is what makes the gesture feel like it is being - * followed. The trade is that a drag can no longer be taken back by dragging the - * other way; in practice, once you have moved 24dp deliberately you meant it, and - * the page you land on is one swipe back. - * - * Deliberately **horizontal-only**. The week and day timelines scroll vertically - * underneath this, and a two-dimensional detector here would claim those drags - * before the inner scroll ever saw them. As it is, a horizontal drag crosses this - * detector's slop while a vertical one is consumed below, and the two coexist. - * (The month view's split style needs a vertical axis as well, so it keeps its - * own axis-locking detector rather than using this.) - */ -@Composable -fun rememberCalendarPageSwipe( - onSwipeNext: () -> Unit, - onSwipePrev: () -> Unit, -): Modifier { - val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() } - return Modifier.pointerInput(onSwipeNext, onSwipePrev) { - var accum = 0f - // One page per gesture: without this a long drag would keep re-firing - // every time the accumulator crossed the threshold again. - var fired = false - detectHorizontalDragGestures( - onDragStart = { - accum = 0f - fired = false - }, - onDragEnd = { - accum = 0f - fired = false - }, - onDragCancel = { - accum = 0f - fired = false - }, - onHorizontalDrag = { _, drag -> - accum += drag - if (!fired) { - val commit = when { - accum < -threshold -> onSwipeNext - accum > threshold -> onSwipePrev - else -> null - } - if (commit != null) { - fired = true - commit() - } - } - }, - ) - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt index d9e01e4..948d1f2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt @@ -1,13 +1,6 @@ package de.jeanlucmakiola.calendula.ui.common -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -19,12 +12,12 @@ import androidx.compose.ui.unit.IntOffset * the reduce-motion check — now live in floret-kit's identity module * (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`, * `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays - * here is only what's specific to paging the calendar grid: the directional - * month/week/day slide and the specs that feed it. + * here is only what's specific to paging the calendar grid: the specs its + * pagers settle with, and that the view switch reuses. */ /** - * The M3 Expressive spatial spring used for the month/week/day slide: the + * The M3 Expressive spatial spring the calendar's motion is built on: the * *default* spring-physics spec from the active motion scheme, rather than a * fixed easing curve. * @@ -59,52 +52,3 @@ fun rememberCalendarPageSpec(): FiniteAnimationSpec = @Composable fun rememberCalendarFadeSpec(): FiniteAnimationSpec = MaterialTheme.motionScheme.defaultEffectsSpec() - -/** - * Navigating between adjacent months/weeks/days, as M3's shared-axis X: the - * outgoing page slides and fades one way while the incoming one arrives from the - * other, position on a spring and opacity on an easing curve. - * - * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump - * (e.g. "today"); a jump reuses the forward direction. - * @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. - * @param fadeSpec effects spec for the opacity half, and for the whole - * transition under reduced motion; typically - * [rememberCalendarFadeSpec]. - * @param reduceMotion when true, drop the movement and cross-fade alone. - */ -fun calendarSlideTransition( - slideDir: Int, - spec: FiniteAnimationSpec, - fadeSpec: FiniteAnimationSpec, - reduceMotion: Boolean, -): ContentTransform { - if (reduceMotion) { - return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) - } - val dir = if (slideDir == 0) 1 else slideDir - return ContentTransform( - targetContentEnter = - slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec), - initialContentExit = - slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec), - // AnimatedContent clips to the animating container by default, which - // shears the pages against the viewport edge as they pass. Left off even - // where the two pages differ in height — the split grid stands as many - // rows as its month spans (#162) — since a page sliding out over the row - // below it reads as travel, and the shear reads as a fault. - sizeTransform = SizeTransform(clip = false), - ) -} - -/** - * How far a page travels, as a fraction of the container width. - * - * A full width was the obvious reading of "paging", but the two pages are - * stacked and both opaque, so a full-width slide showed one grid racing across - * another — the movement carried the whole transition and had a long way to go. - * Under M3's shared-axis pattern the offset only has to *hint* the direction - * while the cross-fade does the swapping, so a fifth of the width is plenty and - * leaves nothing skating past. - */ -private const val SLIDE_TRAVEL_DIVISOR = 5 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 5ee2863..b3ff572 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -284,8 +284,8 @@ class TimelineGeometry { } /** - * Hoisted drag state for one timeline (#68). It lives above the per-page - * `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the + * Hoisted drag state for one timeline (#68). It lives above the pager, so a + * page change mid-drag can't strand a ghost, and the * block it renders is drawn in an overlay, clear of the day column's clip. */ @Stable diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 5bf6f7b..8adc2f6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -12,7 +12,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateMapOf import de.jeanlucmakiola.calendula.ui.common.jumpToPage -import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight +import de.jeanlucmakiola.calendula.ui.common.pagedHeight import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import kotlinx.coroutines.flow.StateFlow @@ -376,7 +376,7 @@ private fun DayPager( onDrop: (TimelineDrop) -> Unit, ) { val stripHeights = remember { mutableStateMapOf() } - val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight) + val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight) DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) { HorizontalPager( state = pagerState, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 9ea8905..348ada3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -2,6 +2,19 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterExitState +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.rememberUpdatedState +import de.jeanlucmakiola.calendula.ui.common.jumpToPage +import de.jeanlucmakiola.calendula.ui.common.pagedHeight +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.Animatable @@ -21,7 +34,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -70,7 +82,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.key import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf @@ -144,7 +155,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure -import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha @@ -158,14 +168,12 @@ import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.itemEnter import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.delay @@ -180,7 +188,6 @@ import kotlinx.datetime.plus import kotlinx.datetime.YearMonth import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime -import kotlin.math.abs import kotlin.time.Clock import kotlin.time.Instant import java.time.format.TextStyle as JavaTextStyle @@ -222,6 +229,27 @@ fun MonthScreen( val scope = rememberCoroutineScope() val scrolling = viewStyle.isScrolling + + // The paged and split styles' months. Made afresh whenever a paged style + // comes on screen, opening on the month the scrolling style was showing: a + // pager that was never laid out can't be scrolled to follow it meanwhile. + val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle() + val pagerState = key(scrolling) { + rememberPagerState(initialPage = anchorPage) { continuousMonthCount() } + } + val pageSpec = rememberCalendarPageSpec() + val reduceMotion = rememberReduceMotion() + // A swipe moves the pager and the anchor follows once it settles; anything + // else that moves the anchor has the pager follow it instead. + LaunchedEffect(anchorPage, scrolling) { + if (scrolling || pagerState.currentPage == anchorPage || pagerState.isScrollInProgress) { + return@LaunchedEffect + } + pagerState.jumpToPage(anchorPage, reduceMotion, pageSpec) + } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled) + } val dense = viewStyle == MonthViewStyle.Dense // Today, from whichever state is driving; the clock only covers the first @@ -260,7 +288,8 @@ fun MonthScreen( } } } - val titleMonth = if (scrolling) visibleMonth else month + // The month under the finger, so the title turns over mid-swipe, not after it. + val titleMonth = if (scrolling) visibleMonth else yearMonthForIndex(pagerState.currentPage) // Feed the visible range back so the sliding data window can follow. Both // styles report *months*, whatever they index by — one window serves both. @@ -313,21 +342,13 @@ fun MonthScreen( } } - // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). - var slideDir by remember { mutableIntStateOf(0) } - - val goNext = { - slideDir = 1 - viewModel.goToNext() + // Straight to the pager as well as the anchor: a tap mid-fling must still + // land, and the anchor hasn't caught up with the fling yet to tell the + // difference. The continuous stream scrolls to the month instead. + val jumpToMonth: (YearMonth) -> Unit = { ym -> + scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) } } - val goPrev = { - slideDir = -1 - viewModel.goToPrev() - } - // Slide toward today: viewing the future → today comes in from the left - // (back), viewing the past → from the right (forward). The continuous stream - // scrolls to today's week instead — there is nothing to slide. - val jumpToToday = { + val jumpToToday: () -> Unit = { if (scrolling) { scope.launch { listState.animateScrollToItem( @@ -335,17 +356,12 @@ fun MonthScreen( else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), ) } - Unit } else { - slideDir = when (val s = state) { - is MonthUiState.Success -> - if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1 - else -> 0 - } viewModel.goToToday() + jumpToMonth(YearMonth(today.year, today.month)) } } - // Drawer jump-to-date: slide from the side the target month lies on. + // Drawer jump-to-date. val jumpToDate: (LocalDate) -> Unit = { target -> if (scrolling) { scope.launch { @@ -355,19 +371,10 @@ fun MonthScreen( ) } } else { - slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1 viewModel.goToDate(target) + jumpToMonth(YearMonth(target.year, target.month)) } } - // Selecting a day in the split grid can cross into a neighbour month — a - // tapped leading/trailing day follows to its own month. Point the slide the - // way the month is actually moving, so the incoming page travels the right - // direction instead of reusing whatever the last swipe left in slideDir. - val selectDay: (LocalDate) -> Unit = { date -> - val target = YearMonth(date.year, date.month) - if (target != month) slideDir = if (target < month) -1 else 1 - viewModel.selectDate(date) - } ModalNavigationDrawer( drawerState = drawerState, @@ -462,12 +469,18 @@ fun MonthScreen( SplitMonthContent( state = state, selected = selectedDate, - slideDir = slideDir, + pagerState = pagerState, + weekStart = weekStart, + monthState = viewModel::month, showWeekNumbers = showWeekNumbers, - onSwipeNext = goNext, - onSwipePrev = goPrev, onRetry = jumpToToday, - onSelectDay = selectDay, + // A tapped leading/trailing day follows to its own + // month, pager and all — straight away, or a swipe + // still settling would land and take the tap back. + onSelectDay = { date -> + viewModel.selectDate(date) + jumpToMonth(YearMonth(date.year, date.month)) + }, onOpenDay = onOpenDay, onEventClick = onEventClick, onCreateEvent = { onCreateEvent(it, null) }, @@ -475,10 +488,10 @@ fun MonthScreen( } else { MonthContent( state = state, - slideDir = slideDir, + pagerState = pagerState, + weekStart = weekStart, + monthState = viewModel::month, showWeekNumbers = showWeekNumbers, - onSwipeNext = goNext, - onSwipePrev = goPrev, onRetry = jumpToToday, onOpenDay = onOpenDay, onEventClick = onEventClick, @@ -595,41 +608,110 @@ private fun MonthDragOverlay(controller: MonthDragController) { @Composable private fun MonthContent( state: MonthUiState, - slideDir: Int, + pagerState: PagerState, + weekStart: DayOfWeek, + monthState: (YearMonth, DayOfWeek) -> StateFlow, showWeekNumbers: Boolean, - onSwipeNext: () -> Unit, - onSwipePrev: () -> Unit, onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, ) { - val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() - val reduceMotion = rememberReduceMotion() - val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev) - + // Loading and failure concern the provider as a whole, so they stand in for + // the pager; a page still loading its own month handles that itself. AnimatedContent( targetState = state, - modifier = Modifier.fillMaxSize().then(swipeModifier), - contentKey = { s -> - when (s) { - is MonthUiState.Success -> "success-${s.month}" - is MonthUiState.Failure -> "failure-${s.reason}" - MonthUiState.Loading -> "loading" - } - }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, - label = "month-transition", + modifier = Modifier.fillMaxSize(), + contentKey = { it::class }, + transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, + label = "month-state", ) { s -> when (s) { MonthUiState.Loading -> MonthGridLoading() is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is MonthUiState.Success -> MonthGrid( - state = s, - showWeekNumbers = showWeekNumbers, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) + is MonthUiState.Success -> MonthPager( + pagerState = pagerState, + weekStart = weekStart, + monthState = monthState, + modifier = Modifier.fillMaxSize(), + ) { _, page -> + MonthGrid( + state = page, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) + } + } + } +} + +/** + * The paged and split styles' months as pages that follow the finger and snap + * once a swipe passes half a page or is flung (#336). Only the page on screen + * carries the chip drag and the morph tags: its neighbours show some of its + * dates among their leading and trailing days, and would otherwise claim its + * drops and its shared elements. + * + * @param static draw the current page alone, without a pager — for a face of the + * split style on its way out, which must not hold on to the pager state the + * incoming face now drives. + */ +@Composable +private fun MonthPager( + pagerState: PagerState, + weekStart: DayOfWeek, + monthState: (YearMonth, DayOfWeek) -> StateFlow, + modifier: Modifier = Modifier, + static: Boolean = false, + loading: @Composable () -> Unit = { MonthGridLoading() }, + page: @Composable (index: Int, state: MonthUiState.Success) -> Unit, +) { + if (static) { + Box(modifier) { + MonthPage(pagerState.currentPage, true, weekStart, monthState, loading, page) + } + return + } + val chipDrag = LocalMonthDrag.current + HorizontalPager( + state = pagerState, + modifier = modifier, + // The months either side are composed ahead, so a swipe lands on a loaded + // month rather than watching it fill in. + beyondViewportPageCount = 1, + verticalAlignment = Alignment.Top, + // A held chip moves within its month; the page stays put under it. + userScrollEnabled = chipDrag?.isDragging != true, + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + snapAnimationSpec = rememberCalendarPageSpec(), + ), + ) { index -> + MonthPage(index, index == pagerState.currentPage, weekStart, monthState, loading, page) + } +} + +@Composable +private fun MonthPage( + index: Int, + active: Boolean, + weekStart: DayOfWeek, + monthState: (YearMonth, DayOfWeek) -> StateFlow, + loading: @Composable () -> Unit, + page: @Composable (index: Int, state: MonthUiState.Success) -> Unit, +) { + val ym = yearMonthForIndex(index) + val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle() + val chipDrag = LocalMonthDrag.current + val morph = LocalMonthMorph.current + CompositionLocalProvider( + LocalMonthDrag provides chipDrag.takeIf { active }, + LocalMonthMorph provides morph.takeIf { active }, + ) { + when (val s = state) { + is MonthUiState.Success -> page(index, s) + else -> loading() } } } @@ -1148,88 +1230,35 @@ internal fun DenseMonthGrid( } } -/** Which way a drag went, decided once per gesture and then held. */ -private enum class DragAxis { Undecided, Horizontal, Vertical } - /** - * The month grid's drag gesture: horizontal pages the month, vertical expands or - * collapses the split style (#53). The grid doesn't follow the finger, so there is - * no distance to rubber-band against — it commits the moment the accumulated drag - * clears the threshold, once per gesture, exactly as - * [rememberCalendarPageSwipe][de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe] - * does for the week and day views. - * - * This is the month's own detector rather than that shared one because it needs - * two axes, and the axis is **locked on the first movement and held for the whole - * gesture** so a drag can page or expand but never both. Two independent - * detectors on one surface would each see their own component of a diagonal drag - * and both fire. - * - * The horizontal threshold is the shared one. The vertical is larger — swapping - * the whole layout out deserves a more deliberate pull than stepping to the next - * month. - * - * [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical - * axis unclaimed: the lock still happens, so a vertical drag there does nothing - * rather than being re-read as a page turn. + * The split style's expand gesture: a downward drag trades the pane away for the + * full grid, an upward one brings it back (#53). Vertical only — the months page + * on the pager inside it, and each detector only claims its own axis at the + * slop, so a drag can page or expand but never both. Commits the moment the drag + * clears [MONTH_EXPAND_THRESHOLD], once per gesture. */ @Composable -private fun rememberMonthSwipeModifier( - onSwipeNext: () -> Unit, - onSwipePrev: () -> Unit, - onExpand: (() -> Unit)? = null, - onCollapse: (() -> Unit)? = null, -): Modifier { - val density = LocalDensity.current - val pageThreshold = with(density) { CALENDAR_SWIPE_THRESHOLD.toPx() } - val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() } - return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) { - var accum = Offset.Zero - var axis = DragAxis.Undecided +private fun rememberSplitExpandDrag(onExpand: () -> Unit, onCollapse: () -> Unit): Modifier { + val threshold = with(LocalDensity.current) { MONTH_EXPAND_THRESHOLD.toPx() } + val currentExpand by rememberUpdatedState(onExpand) + val currentCollapse by rememberUpdatedState(onCollapse) + return Modifier.pointerInput(threshold) { + var accum = 0f + // One commit per gesture: without this a long drag would keep re-firing + // every time the accumulator crossed the threshold again. var fired = false - detectDragGestures( + detectVerticalDragGestures( onDragStart = { - accum = Offset.Zero - axis = DragAxis.Undecided + accum = 0f fired = false }, - onDragEnd = { - accum = Offset.Zero - axis = DragAxis.Undecided - fired = false - }, - onDragCancel = { - accum = Offset.Zero - axis = DragAxis.Undecided - fired = false - }, - onDrag = { _, drag -> + onVerticalDrag = { _, drag -> accum += drag - if (axis == DragAxis.Undecided) { - // Ties go horizontal, keeping paging the default reading of an - // ambiguous drag as it was before the vertical axis existed. - axis = if (abs(accum.x) >= abs(accum.y)) { - DragAxis.Horizontal - } else { - DragAxis.Vertical - } - } - // Both axes commit the instant the drag clears their threshold, - // rather than on release — see [rememberCalendarPageSwipe], which - // does the same for the week and day views. if (!fired) { - val commit = when (axis) { - DragAxis.Horizontal -> when { - accum.x < -pageThreshold -> onSwipeNext - accum.x > pageThreshold -> onSwipePrev - else -> null - } - DragAxis.Vertical -> when { - accum.y > expandThreshold -> onExpand - accum.y < -expandThreshold -> onCollapse - else -> null - } - DragAxis.Undecided -> null + val commit = when { + accum > threshold -> currentExpand + accum < -threshold -> currentCollapse + else -> null } if (commit != null) { fired = true @@ -1265,10 +1294,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp private fun SplitMonthContent( state: MonthUiState, selected: LocalDate, - slideDir: Int, + pagerState: PagerState, + weekStart: DayOfWeek, + monthState: (YearMonth, DayOfWeek) -> StateFlow, showWeekNumbers: Boolean, - onSwipeNext: () -> Unit, - onSwipePrev: () -> Unit, onRetry: () -> Unit, onSelectDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit, @@ -1283,12 +1312,7 @@ private fun SplitMonthContent( // be backed out of. Declared deeper than CalendarHost's view-stack handler, // which is what makes it win while it is enabled. BackHandler(enabled = expanded) { expanded = false } - // The swipe wraps the grid rather than living inside it: mid-transition - // there are two grids, and the gesture belongs to neither. The pane is left - // out of it — it scrolls and is full of tappable rows. - val swipeModifier = rememberMonthSwipeModifier( - onSwipeNext = onSwipeNext, - onSwipePrev = onSwipePrev, + val expandDrag = rememberSplitExpandDrag( onExpand = { expanded = true }, onCollapse = { expanded = false }, ) @@ -1306,6 +1330,9 @@ private fun SplitMonthContent( transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, label = "split-expand-transition", ) { isExpanded -> + // Only the incoming face drives the pager; the outgoing one draws + // its month still for the length of the cross-fade. + val leaving = transition.targetState == EnterExitState.PostExit CompositionLocalProvider( // Null under reduced motion: nothing is tagged, nothing // travels, and the cross-fade above is the whole transition. @@ -1315,119 +1342,98 @@ private fun SplitMonthContent( MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent) }, ) { - SplitMonthBody( - expanded = isExpanded, - state = state, - selected = selected, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - swipeModifier = swipeModifier, - onSelectDay = onSelectDay, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = onCreateEvent, - onSetExpanded = { expanded = it }, - ) + val months = SplitMonths(pagerState, weekStart, monthState, static = leaving) + if (isExpanded) { + SplitMonthExpanded( + months = months, + selected = selected, + showWeekNumbers = showWeekNumbers, + expandDrag = expandDrag, + // A tap in the expanded grid picks the day and drops + // back, which gives the expanded month a job — a + // chooser you dip into — rather than a mode you can + // get stranded in. The collapse then runs with the + // selection already set, so the pane arrives showing + // the day you picked. + onPickDay = { + onSelectDay(it) + expanded = false + }, + // A tapped chip is asking for that event, not for its + // day, so it opens the detail from here too rather + // than collapsing (#187). + onEventClick = onEventClick, + onCollapse = { expanded = false }, + ) + } else { + SplitMonthCollapsed( + state = state, + months = months, + selected = selected, + showWeekNumbers = showWeekNumbers, + expandDrag = expandDrag, + onSelectDay = onSelectDay, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + onExpand = { expanded = true }, + ) + } } } } } } -/** The two faces of the split style, sharing one set of morph tags. */ -@Composable -private fun SplitMonthBody( - expanded: Boolean, - state: MonthUiState.Success, - selected: LocalDate, - slideDir: Int, - showWeekNumbers: Boolean, - swipeModifier: Modifier, - onSelectDay: (LocalDate) -> Unit, - onOpenDay: (LocalDate) -> Unit, - onEventClick: (EventInstance) -> Unit, - onCreateEvent: (LocalDate) -> Unit, - onSetExpanded: (Boolean) -> Unit, -) { - if (expanded) { - SplitMonthExpanded( - state = state, - selected = selected, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - swipeModifier = swipeModifier, - // A tap in the expanded grid picks the day and drops back, which - // gives the expanded month a job — a chooser you dip into — rather - // than a mode you can get stranded in. The collapse then runs with - // the selection already set, so the pane arrives showing the day - // you picked. - onPickDay = { - onSelectDay(it) - onSetExpanded(false) - }, - // A tapped chip is asking for that event, not for its day, so it - // opens the detail from here too rather than collapsing (#187). - onEventClick = onEventClick, - onCollapse = { onSetExpanded(false) }, - ) - } else { - SplitMonthCollapsed( - state = state, - selected = selected, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - swipeModifier = swipeModifier, - onSelectDay = onSelectDay, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = onCreateEvent, - onExpand = { onSetExpanded(true) }, - ) - } -} +/** What both faces of the split style page through — see [MonthPager]. */ +private class SplitMonths( + val pagerState: PagerState, + val weekStart: DayOfWeek, + val monthState: (YearMonth, DayOfWeek) -> StateFlow, + val static: Boolean, +) /** The split style at rest: compact grid, handle, then the selected day's events. */ @Composable private fun SplitMonthCollapsed( state: MonthUiState.Success, + months: SplitMonths, selected: LocalDate, - slideDir: Int, showWeekNumbers: Boolean, - swipeModifier: Modifier, + expandDrag: Modifier, onSelectDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, onExpand: () -> Unit, ) { - val slideSpec = rememberCalendarSlideSpec() - val fadeSpec = rememberCalendarFadeSpec() - val reduceMotion = rememberReduceMotion() + // The grid stands only as many rows tall as its month spans (#162), so a + // swipe between a five-row and a six-row month moves the pane by a row; the + // blend has it move with the finger rather than jump on settle. + val gridHeights = remember { mutableStateMapOf() } + val gridHeight = pagedHeight(months.pagerState, gridHeights, splitGridHeight(state.weeks.size)) Column(Modifier.fillMaxSize()) { // Grid and handle drag as one surface — the handle is what advertises the // gesture, so it has to answer to it as well as to a tap. The pane is // outside: it scrolls, and is full of tappable rows. - Column(swipeModifier) { - AnimatedContent( - // The selection travels *with* the state so each page keeps its - // own. Read from outside, both pages would show the incoming - // one, and paging visibly threw the marker across the outgoing - // grid — onto the new month's 1st, which the old grid still - // shows among its trailing days — before the new page arrived. - targetState = state to selected, - // Keyed on the month alone, so a provider notification refreshing - // the month you are on — or a tap moving the selection within it - // — updates in place instead of sliding. - contentKey = { (s, _) -> s.month }, - transitionSpec = { - calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) - }, - label = "split-month-transition", - ) { (s, sel) -> + Column(expandDrag) { + MonthPager( + pagerState = months.pagerState, + weekStart = months.weekStart, + monthState = months.monthState, + static = months.static, + modifier = Modifier.fillMaxWidth().height(gridHeight), + loading = {}, + ) { index, page -> + val height = splitGridHeight(page.weeks.size) + DisposableEffect(index, height) { + gridHeights[index] = height + onDispose { gridHeights.remove(index) } + } SplitMonthGrid( - state = s, - selected = sel, + state = page, + selected = selected, showWeekNumbers = showWeekNumbers, onSelectDay = onSelectDay, ) @@ -1461,38 +1467,31 @@ private fun SplitMonthCollapsed( */ @Composable private fun SplitMonthExpanded( - state: MonthUiState.Success, + months: SplitMonths, /** Marked nowhere here; it only anchors the outline's morph. */ selected: LocalDate, - slideDir: Int, showWeekNumbers: Boolean, - swipeModifier: Modifier, + expandDrag: Modifier, onPickDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onCollapse: () -> Unit, ) { - val slideSpec = rememberCalendarSlideSpec() - val fadeSpec = rememberCalendarFadeSpec() - val reduceMotion = rememberReduceMotion() - // The whole screen drags here, handle included — there is no pane to keep out // of it, and the handle is the obvious thing to reach for on the way back. - Column(Modifier.fillMaxSize().then(swipeModifier)) { - AnimatedContent( - targetState = state to selected, + Column(Modifier.fillMaxSize().then(expandDrag)) { + MonthPager( + pagerState = months.pagerState, + weekStart = months.weekStart, + monthState = months.monthState, + static = months.static, modifier = Modifier.weight(1f), - contentKey = { (s, _) -> s.month }, - transitionSpec = { - calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) - }, - label = "split-expanded-month-transition", - ) { (s, sel) -> + ) { _, page -> MonthGrid( - state = s, + state = page, showWeekNumbers = showWeekNumbers, onOpenDay = onPickDay, onEventClick = onEventClick, - selected = sel, + selected = selected, ) } SplitExpandHandle(expanded = true, onToggle = onCollapse) @@ -1544,6 +1543,15 @@ private fun SplitExpandHandle( * up goes to the day pane below. */ private val SPLIT_ROW_HEIGHT = 46.dp + +/** Vertical padding and row gap of [SplitMonthGrid], which [splitGridHeight] pays for. */ +private val SPLIT_GRID_VERTICAL_PADDING = 4.dp +private val SPLIT_ROW_GAP = 2.dp + +/** How tall [SplitMonthGrid] stands for a month spanning [rows] weeks. */ +private fun splitGridHeight(rows: Int): Dp = + SPLIT_ROW_HEIGHT * rows + SPLIT_ROW_GAP * (rows - 1).coerceAtLeast(0) + + SPLIT_GRID_VERTICAL_PADDING * 2 private val SPLIT_DOT_SIZE = 5.dp // Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for // the paged grid's lanes, so the two caps have to be the same number or a dot @@ -1583,8 +1591,8 @@ internal fun SplitMonthGrid( Column( modifier = modifier .fillMaxWidth() - .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + .padding(horizontal = AppBarSpacing.Inset, vertical = SPLIT_GRID_VERTICAL_PADDING), + verticalArrangement = Arrangement.spacedBy(SPLIT_ROW_GAP), ) { state.weeks.forEach { week -> Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 9123823..04a5af2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.calendarListFailure +import de.jeanlucmakiola.calendula.ui.common.PageStateCache import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay @@ -25,6 +26,9 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlin.math.abs import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit @@ -44,6 +48,9 @@ import kotlin.time.Clock import kotlin.time.Instant import javax.inject.Inject +/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */ +private const val MONTH_CACHE_SPAN = 2 + @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class MonthViewModel @Inject constructor( @@ -87,6 +94,21 @@ class MonthViewModel @Inject constructor( private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) val month: StateFlow = _month + /** The pager page the anchor month sits on, for the paged and split styles. */ + val anchorPage: StateFlow = _month + .map { monthIndexOf(it) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = monthIndexOf(_month.value), + ) + + /** + * The anchor month's state. Once loaded it never falls back to + * [MonthUiState.Loading]: moving the anchor keeps the last result until the + * new month arrives, since the pages draw their own months and this feeds + * the failure screen and the split style's day pane. + */ val state: StateFlow = combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) } .flatMapLatest { (ym, ws, style) -> @@ -94,22 +116,48 @@ class MonthViewModel @Inject constructor( // Split draw, so under a scrolling style this query is a month // of provider work for a view that isn't on screen. if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading) - val range = monthGridRange(ym, ws, zone) - combine( - repository.calendars(), - repository.instances(range), - ) { calendars, instances -> - buildState(ym, ws, calendars, instances) - } + month(ym, ws).filterNot { it is MonthUiState.Loading } } .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = MonthUiState.Loading, ) + private val months = PageStateCache, MonthUiState>( + scope = viewModelScope, + initial = MonthUiState.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. A week-start + // change lays every month out afresh. + keep = { (cached, cachedWs), (requested, ws) -> + cachedWs == ws && abs(monthIndexOf(cached) - monthIndexOf(requested)) <= MONTH_CACHE_SPAN + }, + ) { (ym, ws) -> + combine( + repository.calendars(), + repository.instances(monthGridRange(ym, ws, zone)), + ) { calendars, instances -> + buildState(ym, ws, calendars, instances) + } + .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + } + + /** + * Month [ym] laid out with weeks starting on [ws], shared between its pager + * page and [state]. Main thread only. + */ + fun month(ym: YearMonth, ws: DayOfWeek): StateFlow = months.get(ym to ws) + + /** The pager came to rest on [page]; follow it unless it is already the anchor's. */ + fun onPageSettled(page: Int) { + if (monthIndexOf(_month.value) == page) return + _month.value = yearMonthForIndex(page) + realignSelection() + } + // --- Continuous + Dense styles (#38) ---------------------------------- // // These scroll through every month there is, so they can't load "a month" — @@ -271,16 +319,6 @@ class MonthViewModel @Inject constructor( _selectedDate.value = selectionForMonth(_month.value, todayDate) } - fun goToPrev() { - _month.value = _month.value.minus(1, DateTimeUnit.MONTH) - realignSelection() - } - - fun goToNext() { - _month.value = _month.value.plus(1, DateTimeUnit.MONTH) - realignSelection() - } - fun goToToday() { _month.value = YearMonth(todayDate.year, todayDate.month) _selectedDate.value = todayDate 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 78a3933..a9bb959 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 @@ -12,7 +12,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateMapOf import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec import de.jeanlucmakiola.calendula.ui.common.jumpToPage -import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight +import de.jeanlucmakiola.calendula.ui.common.pagedHeight import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.drop import kotlinx.datetime.DayOfWeek @@ -418,7 +418,7 @@ private fun WeekPager( onDrop: (TimelineDrop) -> Unit, ) { val stripHeights = remember { mutableStateMapOf() } - val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight) + val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight) WeekFrame( weekStart = weekStartForPage(pagerState.currentPage, firstDay), allDayHeight = allDayHeight, From e8ed4c30e19742010da16aa5ce04518d872558b9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 25 Sep 2026 21:25:17 +0200 Subject: [PATCH 2/2] Add a multi-day view with a configurable day count (#193) --- .../jeanlucmakiola/calendula/MainActivity.kt | 2 + .../calendula/data/prefs/SettingsPrefs.kt | 28 ++- .../calendula/ui/CalendarHost.kt | 13 ++ .../calendula/ui/common/CalendarDrawer.kt | 2 +- .../calendula/ui/common/CalendarView.kt | 42 +++- .../calendula/ui/common/ViewSwitcherPill.kt | 2 +- .../calendula/ui/onboarding/ViewStep.kt | 10 +- .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 15 +- .../calendula/ui/settings/ViewsSettings.kt | 34 +++- .../calendula/ui/week/WeekPaging.kt | 50 +++++ .../calendula/ui/week/WeekScreen.kt | 181 +++++++++++++----- .../calendula/ui/week/WeekViewModel.kt | 138 ++++++++----- .../calendula/ui/week/WeekViewPreview.kt | 11 +- app/src/main/res/values/strings.xml | 7 + .../calendula/data/prefs/SettingsPrefsTest.kt | 25 ++- .../calendula/ui/week/WeekLayoutTest.kt | 4 +- .../calendula/ui/week/WeekPagingTest.kt | 20 ++ 18 files changed, 454 insertions(+), 132 deletions(-) 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)) + } }