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 dba9535..d9e01e4 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 @@ -40,6 +40,12 @@ import androidx.compose.ui.unit.IntOffset fun rememberCalendarSlideSpec(): FiniteAnimationSpec = MaterialTheme.motionScheme.defaultSpatialSpec() +/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun rememberCalendarPageSpec(): FiniteAnimationSpec = + MaterialTheme.motionScheme.defaultSpatialSpec() + /** * The effects spec from the active motion scheme, for the opacity half of the * transition. Captured in composable scope alongside [rememberCalendarSlideSpec] 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 new file mode 100644 index 0000000..a3ff208 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -0,0 +1,25 @@ +package de.jeanlucmakiola.calendula.ui.week + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.daysUntil +import kotlinx.datetime.plus + +/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */ +internal const val WEEK_PAGE_COUNT: Int = 20_000 + +private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2 + +/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */ +private val PAGE_EPOCH = LocalDate(2000, 1, 3) + +/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */ +internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate = + PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY) + +/** The pager page whose week contains [date], with weeks starting on [firstDay]. */ +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) +} 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 1867c93..afcddc8 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 @@ -1,7 +1,24 @@ package de.jeanlucmakiola.calendula.ui.week import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +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.DisposableEffect +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.unit.lerp +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.datetime.DayOfWeek +import kotlin.math.floor import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -48,12 +65,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.key import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -69,6 +84,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -122,9 +138,7 @@ import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.NowLine 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.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale @@ -142,7 +156,6 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.flow.first @@ -152,7 +165,6 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.plus import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle import java.util.Locale @@ -162,6 +174,14 @@ private val ALL_DAY_VERTICAL_PADDING = 6.dp /** Gap between day columns; part of the column pitch a drag maps positions through. */ private val COLUMN_GAP = 2.dp +/** Breathing room between the header block and the scrolling timeline below. */ +private val TIMELINE_TOP_GAP = 8.dp +private val HEADER_TOP_PADDING = 4.dp +private val HEADER_BOTTOM_PADDING = 8.dp + +/** The header's date slot, reserved whether or not it holds today's circle. */ +private val DATE_SLOT_SIZE = 28.dp + /** Total all-day strip height for a week (0 when there are no all-day events). */ internal fun WeekUiState.Success.allDayStripHeight(): Dp { if (allDaySpans.isEmpty()) return 0.dp @@ -186,7 +206,8 @@ fun WeekScreen( viewModel: WeekViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle() + val firstDay by viewModel.firstDayOfWeek.collectAsStateWithLifecycle() + val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle() 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 @@ -199,39 +220,47 @@ fun WeekScreen( val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - val isOnCurrentWeek = when (val s = state) { - // True when today falls inside the displayed week — independent of which - // weekday the user picked as the first day. - is WeekUiState.Success -> - s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) - else -> true - } - - // Drives whether the title carries the year. Falls back to the clock only - // while the first load is in flight, when there is no state to read today from. - val currentYear = when (val s = state) { - is WeekUiState.Success -> s.today.year - else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year - } - - // Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump. - var slideDir by remember { mutableIntStateOf(0) } - val goNext = { slideDir = 1; viewModel.goToNext() } - 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). - val jumpToToday = { - slideDir = when (val s = state) { - is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1 - else -> 0 + val pagerState = rememberPagerState(initialPage = anchorPage) { WEEK_PAGE_COUNT } + 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) { + if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) { + pagerState.scrollToPage(anchorPage) } - viewModel.goToToday() } - // Drawer jump-to-date: slide from the side the target week lies on. + // The page it opened on is skipped: a first-day change landing in between + // would read it as a different week. + 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) + + // Off the ticking clock rather than the loaded week, 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)) + + // 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. val jumpToDate: (LocalDate) -> Unit = { target -> - slideDir = if (target < weekStart) -1 else 1 - viewModel.goToDate(target) + val page = weekPageFor(target, firstDay) + scope.launch { + if (reduceMotion) { + pagerState.scrollToPage(page) + } else { + pagerState.animateScrollToPage(page, animationSpec = pageSpec) + } + } } + val jumpToToday = { jumpToDate(today) } ModalNavigationDrawer( drawerState = drawerState, @@ -263,7 +292,7 @@ fun WeekScreen( topBar = { WeekTopBar( weekStart = weekStart, - currentYear = currentYear, + currentYear = today.year, selectedView = selectedView, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, quickSwitchViews = quickSwitchViews, @@ -281,8 +310,6 @@ fun WeekScreen( onToday = jumpToToday, onCreate = { // Anchor on today when it's in view, else the week's first day. - val today = Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()).date onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) }, ) @@ -291,10 +318,10 @@ fun WeekScreen( CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { WeekContent( state = state, - slideDir = slideDir, - onSwipeNext = goNext, - onSwipePrev = goPrev, - onRetry = jumpToToday, + pagerState = pagerState, + firstDay = firstDay, + week = viewModel::week, + onRetry = viewModel::goToToday, onEventClick = onEventClick, onOpenDay = onOpenDay, onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, @@ -310,20 +337,18 @@ fun WeekScreen( @Composable private fun WeekContent( state: WeekUiState, - slideDir: Int, - onSwipeNext: () -> Unit, - onSwipePrev: () -> Unit, + pagerState: PagerState, + firstDay: DayOfWeek, + week: (LocalDate) -> StateFlow, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, onOpenDay: (LocalDate) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() - val reduceMotion = rememberReduceMotion() - // Hoisted above the per-week AnimatedContent so the vertical scroll position + // Shared by every page and the gutter, so the vertical scroll position // survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on // noon once, on first entry into the week view (i.e. when arriving from the // month/day view), not on every swipe. @@ -335,62 +360,47 @@ private fun WeekContent( scrollState.scrollTo(scrollState.maxValue / 2) } - // Single, hoisted all-day strip height — shared by the outgoing and incoming - // week during a swipe, so the strip slides along but never jumps in height; - // it just springs smoothly from the old to the new size. - val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp - val allDayHeight by animateDpAsState( - targetValue = targetAllDayHeight, - label = "all-day-strip-height", - ) - - // Whole-page horizontal swipe. It sits one level above the timeline's - // vertical scroll: a horizontal drag only crosses *this* detector's slop, - // while a vertical drag is consumed by the inner scroll first — so the two - // gestures coexist without fighting. - val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) - - // Above the AnimatedContent: a page change mid-drag would strand the - // floating block inside the outgoing page. + // Above the pager: a page change mid-drag would strand the floating block + // inside the outgoing page. val dragController = rememberTimelineDragController() val move = LocalEventMove.current val zone = remember { TimeZone.currentSystemDefault() } + val onDrop: (TimelineDrop) -> Unit = { drop -> + move?.move( + MoveRequest( + eventId = drop.event.eventId, + beginMillis = drop.event.start.toEpochMilliseconds(), + endMillis = drop.event.end.toEpochMilliseconds(), + target = MoveTarget.Start(drop.startInstant(zone)), + ), + ) + } Box(modifier = modifier) { + // Loading and failure concern the provider as a whole, so they stand in + // for the pager; a page still loading its own week handles that itself. AnimatedContent( targetState = state, - modifier = Modifier.fillMaxSize().then(swipeModifier), - contentKey = { s -> - when (s) { - is WeekUiState.Success -> "success-${s.weekStart}" - is WeekUiState.Failure -> "failure-${s.reason}" - WeekUiState.Loading -> "loading" - } - }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, - label = "week-transition", + modifier = Modifier.fillMaxSize(), + contentKey = { it::class }, + transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, + label = "week-state", ) { s -> when (s) { WeekUiState.Loading -> WeekLoading() is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is WeekUiState.Success -> WeekSuccess( - state = s, + is WeekUiState.Success -> WeekPager( + pagerState = pagerState, + firstDay = firstDay, + week = week, + today = s.today, + initialStripHeight = s.allDayStripHeight(), scrollState = scrollState, - allDayHeight = allDayHeight, dragController = dragController, onEventClick = onEventClick, onOpenDay = onOpenDay, onCreateAt = onCreateAt, - onDrop = { drop -> - move?.move( - MoveRequest( - eventId = drop.event.eventId, - beginMillis = drop.event.start.toEpochMilliseconds(), - endMillis = drop.event.end.toEpochMilliseconds(), - target = MoveTarget.Start(drop.startInstant(zone)), - ), - ) - }, + onDrop = onDrop, ) } } @@ -398,6 +408,150 @@ private fun WeekContent( } } +/** + * The weeks as pages that follow the finger and snap once a swipe passes half a + * page or is flung (#131), beside a gutter that stays put. + */ +@Composable +private fun WeekPager( + pagerState: PagerState, + firstDay: DayOfWeek, + week: (LocalDate) -> StateFlow, + today: LocalDate, + initialStripHeight: Dp, + scrollState: ScrollState, + dragController: TimelineDragController, + onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, +) { + val stripHeights = remember { mutableStateMapOf() } + val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight) + WeekFrame( + weekStart = weekStartForPage(pagerState.currentPage, firstDay), + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + // The weeks either side are composed ahead, so a swipe lands on a + // loaded week rather than watching it fill in. + beyondViewportPageCount = 1, + // A held block moves within its week; the page stays put under it. + userScrollEnabled = !dragController.isDragging, + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + snapAnimationSpec = rememberCalendarPageSpec(), + ), + ) { page -> + val start = weekStartForPage(page, firstDay) + val pageState by remember(start) { week(start) }.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) } + val stripHeight = loaded?.allDayStripHeight() + DisposableEffect(page, stripHeight) { + if (stripHeight != null) stripHeights[page] = stripHeight + onDispose { stripHeights.remove(page) } + } + WeekPage( + state = loaded ?: empty, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = page == pagerState.currentPage, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } + } +} + +/** + * One all-day strip height for every page, blended between the two weeks a swipe + * sits between so their timelines stay level mid-swipe. It follows the swipe + * directly and only springs when a week's own strip changes at rest. [fallback] + * stands in until the pages have reported theirs, so it doesn't open from zero. + */ +@Composable +private fun pagedStripHeight(pagerState: PagerState, heights: Map, fallback: Dp): Dp { + val currentFallback by rememberUpdatedState(fallback) + val target by remember(pagerState, heights) { + derivedStateOf { + val position = pagerState.currentPage + pagerState.currentPageOffsetFraction + val from = floor(position).toInt() + val a = heights[from] + val b = heights[from + 1] + lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from) + } + } + val height = remember { Animatable(target, Dp.VectorConverter) } + LaunchedEffect(height) { + snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) -> + if (scrolling) height.snapTo(h) else height.animateTo(h) + } + } + return height.value +} + +/** 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) } + return WeekUiState.Success( + weekStart = start, + today = today, + days = days, + allDaySpans = emptyList(), + timedByDay = emptyMap(), + ) +} + +/** + * What stays put while the weeks swipe: the week-number badge and the hour + * gutter, with [pages] filling the rest. + */ +@Composable +private fun WeekFrame( + weekStart: LocalDate, + allDayHeight: Dp, + scrollState: ScrollState, + dragController: TimelineDragController, + pages: @Composable () -> Unit, +) { + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface), + ) { + WeekNumberCell(weekStart) + Spacer(Modifier.height(allDayHeight)) + } + Spacer(Modifier.height(TIMELINE_TOP_GAP)) + // Resolves the hour height off the same viewport height the pages' + // timelines have, so labels and columns agree. + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val zoom = LocalTimelineZoom.current + val hourHeight = zoom.scale.hourHeight(maxHeight) + HourGutter( + scrollState = scrollState, + hourHeight = hourHeight, + dragController = dragController, + modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom), + ) + } + } + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() } + } +} + +/** A single week in its frame, without the pager — for the Settings preview. */ @Composable internal fun WeekSuccess( state: WeekUiState.Success, @@ -408,6 +562,44 @@ internal fun WeekSuccess( onOpenDay: (LocalDate) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, +) { + WeekFrame( + weekStart = state.weekStart, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + ) { + WeekPage( + state = state, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = true, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } +} + +/** + * One week's page: header, all-day strip and day columns. + * + * @param active whether this is the page on screen, the only one that may + * publish the drag geometry. + */ +@Composable +private fun WeekPage( + state: WeekUiState.Success, + allDayHeight: Dp, + scrollState: ScrollState, + dragController: TimelineDragController, + active: Boolean, + onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { Column( @@ -424,13 +616,12 @@ internal fun WeekSuccess( AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) } } - // Breathing room between the top section and the scrolling timeline - // below. - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(TIMELINE_TOP_GAP)) Timeline( state = state, scrollState = scrollState, dragController = dragController, + active = active, onEventClick = onEventClick, onCreateAt = onCreateAt, onDrop = onDrop, @@ -505,24 +696,11 @@ private fun WeekDayHeader( onOpenDay: (LocalDate) -> Unit, ) { val locale = currentLocale() - val weekStart = days.first() - val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } Row( modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET), + .padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET), ) { - // Mirror the day-column layout (empty weekday line + spacer) so the - // badge lines up vertically with the date numbers. The start inset centres - // the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET). - Column( - modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = " ", style = MaterialTheme.typography.labelSmall) - Spacer(Modifier.height(2.dp)) - WeekNumberBadge(weekNumber = weekNumber) - } days.forEach { date -> val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) val isToday = date == today @@ -533,16 +711,19 @@ private fun WeekDayHeader( .clickable { onOpenDay(date) }, horizontalAlignment = Alignment.CenterHorizontally, ) { + // One line, as the gutter cell beside it assumes (see WeekNumberCell). Text( text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale), style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Clip, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(2.dp)) - // Always reserve the 28dp circle slot so the header height is + // Always reserve the circle slot so the header height is // identical whether or not the week contains today. Box( - modifier = Modifier.size(28.dp), + modifier = Modifier.size(DATE_SLOT_SIZE), contentAlignment = Alignment.Center, ) { if (isToday) { @@ -572,6 +753,33 @@ private fun WeekDayHeader( } } +/** + * The header's gutter cell, laid out like a day's (empty weekday line, spacer, + * date slot) so the badge lines up with the date numbers and the cell is exactly + * as tall as the header beside it. The start inset centres the badge on the top + * bar's hamburger (see GUTTER_CONTENT_START_INSET). + */ +@Composable +private fun WeekNumberCell(weekStart: LocalDate) { + val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = GUTTER_CONTENT_START_INSET, + top = HEADER_TOP_PADDING, + bottom = HEADER_BOTTOM_PADDING, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = " ", style = MaterialTheme.typography.labelSmall) + Spacer(Modifier.height(2.dp)) + Box(modifier = Modifier.height(DATE_SLOT_SIZE), contentAlignment = Alignment.TopCenter) { + WeekNumberBadge(weekNumber = weekNumber) + } + } +} + /** Calendar-week badge shown in the header gutter, deliberately set apart with a * filled box and bold number — at the month grid's size, so the two agree (#213). */ @Composable @@ -612,8 +820,6 @@ private fun AllDayStrip( end = TIMELINE_CONTENT_END_INSET, ), ) { - // Keep the gutter-width offset so the bars line up with the day columns. - Spacer(Modifier.width(GUTTER_WIDTH)) // Span bars are positioned absolutely so a multi-day event is one // connected bar across columns rather than a chip per day. clipToBounds // keeps bars from spilling out while the height animates. @@ -682,6 +888,7 @@ private fun Timeline( state: WeekUiState.Success, scrollState: ScrollState, dragController: TimelineDragController, + active: Boolean, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, @@ -703,70 +910,61 @@ private fun Timeline( BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val hourHeight = zoom.scale.hourHeight(maxHeight) val totalHeight = hourHeight * 24 - // The pinch sits on the Row, above both scroll viewports: it has to - // outrank the vertical scroll, and it does that by watching the initial - // pass, which only reaches it if it is their ancestor. + // The pinch sits above the scroll viewport: it has to outrank the + // vertical scroll, and it does that by watching the initial pass, which + // only reaches it if it is the viewport's ancestor. val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom) - // Gutter and day columns are two scroll viewports that SHARE one scroll - // state, so they stay perfectly aligned. The day-column viewport is a - // static, rounded-clipped window — the content scrolls inside it, so the - // soft corners are permanent at any scroll position (not just at the - // day's start/end). - Row(modifier = Modifier.fillMaxSize().then(pinch)) { - // Hour gutter (scrolls in sync with the day columns). Same start inset - // as the header badge so the labels sit under it and on the hamburger. - HourGutter( - scrollState = scrollState, - hourHeight = hourHeight, - dragController = dragController, - ) - // Day columns: rounded, clipped scroll viewport (permanent corners). - Box( + // Scrolls on the same state as the gutter and the other pages, so they + // all stay aligned. A static, rounded-clipped window — the content + // scrolls inside it, so the soft corners are permanent at any scroll + // position (not just at the day's start/end). + Box( + modifier = Modifier + .fillMaxSize() + .then(pinch) + .padding(end = TIMELINE_CONTENT_END_INSET) + .clip(RoundedCornerShape(16.dp)) + .verticalScroll(scrollState) + .onGloballyPositioned { if (active) dragController.geometry.viewport = it }, + ) { + Row( modifier = Modifier - .weight(1f) - .fillMaxHeight() - .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)) - .verticalScroll(scrollState) - .onGloballyPositioned { dragController.geometry.viewport = it }, + .fillMaxWidth() + .height(totalHeight) + // The scrolling content itself, so its root position + // already folds in the scroll offset. Only the page on + // screen publishes it: the ones either side are laid out too. + .onGloballyPositioned { coords -> + if (!active) return@onGloballyPositioned + val gap = with(density) { COLUMN_GAP.toPx() } + dragController.geometry.let { + it.grid = coords + it.scroll = scrollState + it.hourPx = with(density) { hourHeight.toPx() } + it.blockInsetPx = blockInsetPx + it.columnGapPx = gap + it.columnWidthPx = (coords.size.width + gap) / state.days.size + it.days = state.days + it.isRtl = isRtl + } + }, + horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(totalHeight) - // The scrolling content itself, so its root position - // already folds in the scroll offset. - .onGloballyPositioned { coords -> - val gap = with(density) { COLUMN_GAP.toPx() } - dragController.geometry.let { - it.grid = coords - it.scroll = scrollState - it.hourPx = with(density) { hourHeight.toPx() } - it.blockInsetPx = blockInsetPx - it.columnGapPx = gap - it.columnWidthPx = (coords.size.width + gap) / state.days.size - it.days = state.days - it.isRtl = isRtl - } - }, - horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP), - ) { - state.days.forEach { day -> - DayColumnCard( - blocks = state.timedByDay[day].orEmpty(), - dark = dark, - date = day, - today = state.today, - hourHeight = hourHeight, - dragController = dragController, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - ) - } + state.days.forEach { day -> + DayColumnCard( + blocks = state.timedByDay[day].orEmpty(), + dark = dark, + date = day, + today = state.today, + hourHeight = hourHeight, + dragController = dragController, + onEventClick = onEventClick, + onCreateAt = onCreateAt, + onDrop = onDrop, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) } } } 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 53c34eb..9293c40 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 @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -30,6 +31,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime +import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toInstant @@ -38,9 +40,13 @@ import java.util.Locale import kotlin.time.Clock import kotlin.time.Instant import javax.inject.Inject +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 + @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class WeekViewModel @Inject constructor( @@ -64,7 +70,7 @@ class WeekViewModel @Inject constructor( get() = Clock.System.now().toLocalDateTime(zone).date /** First day of the week, from the Settings preference (AUTO → locale). */ - private val weekStart: StateFlow = settingsPrefs.weekStart + val firstDayOfWeek: StateFlow = settingsPrefs.weekStart .map { it.resolveFirstDay(locale) } .stateIn( scope = viewModelScope, @@ -73,55 +79,70 @@ class WeekViewModel @Inject constructor( ) // Anchor is a representative day inside the visible week; the actual week - // start is derived against [weekStart], so changing the first-day preference - // re-frames the same week instead of jumping. + // start is derived against [firstDayOfWeek], so changing the first-day + // preference re-frames the same week instead of jumping. private val _anchor = MutableStateFlow(todayDate) - val weekStartDate: StateFlow = - combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) } + /** The pager page the anchor's week sits on. */ + val anchorPage: StateFlow = + combine(_anchor, firstDayOfWeek) { anchor, ws -> weekPageFor(anchor, ws) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), - initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY), + initialValue = weekPageFor(todayDate, DayOfWeek.MONDAY), ) + /** + * 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. + */ val state: StateFlow = - combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) } + combine(_anchor, firstDayOfWeek) { anchor, ws -> anchor.startOfWeek(ws) } .distinctUntilChanged() - .flatMapLatest { start -> - val range = weekRange(start, zone) - combine( - repository.calendars(), - repository.instances(range), - ) { calendars, instances -> - buildState(start, calendars, instances) - } - } - .catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) + .flatMapLatest { start -> week(start).filterNot { it is WeekUiState.Loading } } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = WeekUiState.Loading, ) - fun goToPrev() { - _anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY) + private val weeks = HashMap>() + + /** + * The week starting at [start], shared between its pager page and [state]. + * Weeks far from the one asked for are dropped, so paging through a year + * doesn't keep a year of queries around. Main thread only. + */ + fun week(start: LocalDate): StateFlow { + weeks.keys.removeAll { abs(it.daysUntil(start)) > WEEK_CACHE_DAYS } + return weeks.getOrPut(start) { + combine( + repository.calendars(), + repository.instances(weekRange(start, zone)), + ) { calendars, instances -> + buildState(start, calendars, instances) + } + .catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = WeekUiState.Loading, + ) + } } - fun goToNext() { - _anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY) + /** 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) } fun goToToday() { _anchor.value = todayDate } - /** Jump to the week containing [date] (drawer jump-to-date). */ - fun goToDate(date: LocalDate) { - _anchor.value = date - } - private fun buildState( start: LocalDate, calendars: List, 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 new file mode 100644 index 0000000..8514d9b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.calendula.ui.week + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import org.junit.jupiter.api.Test + +class WeekPagingTest { + + // 2026-09-27 is a Sunday. + private val sunday = LocalDate(2026, 9, 27) + + @Test + fun `a page and its week round-trip for every first day`() { + DayOfWeek.entries.forEach { firstDay -> + val page = weekPageFor(sunday, firstDay) + val start = weekStartForPage(page, firstDay) + assertThat(start).isEqualTo(sunday.startOfWeek(firstDay)) + assertThat(weekPageFor(start, firstDay)).isEqualTo(page) + } + } + + @Test + fun `every day of a week maps to the same page`() { + val start = sunday.startOfWeek(DayOfWeek.MONDAY) + val pages = (0..6).map { weekPageFor(start.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } + assertThat(pages.toSet()).hasSize(1) + } + + @Test + fun `adjacent pages are adjacent weeks`() { + val page = weekPageFor(sunday, DayOfWeek.SUNDAY) + assertThat(weekStartForPage(page + 1, DayOfWeek.SUNDAY)) + .isEqualTo(LocalDate(2026, 10, 4)) + assertThat(weekStartForPage(page - 1, DayOfWeek.SUNDAY)) + .isEqualTo(LocalDate(2026, 9, 20)) + } + + @Test + fun `dates before the epoch page resolve too`() { + val old = LocalDate(1990, 5, 16) + val page = weekPageFor(old, DayOfWeek.MONDAY) + assertThat(weekStartForPage(page, DayOfWeek.MONDAY)).isEqualTo(LocalDate(1990, 5, 14)) + } +}