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 new file mode 100644 index 0000000..4aa991e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPager.kt @@ -0,0 +1,55 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.VectorConverter +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.lerp +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. + * + * @param heights each composed page's own strip height, by page index. + */ +@Composable +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 deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */ +suspend fun PagerState.jumpToPage( + page: Int, + reduceMotion: Boolean, + spec: FiniteAnimationSpec, +) { + if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt new file mode 100644 index 0000000..8358820 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.calendula.ui.common + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus + +/** + * One shared state per pager page — a week, a day, a month — so a page and the + * screen's anchor read the same query, and the pages either side stay loaded + * across a swipe. + * + * Every entry shares on its own job, cancelled when [keep] lets it go: a + * `stateIn` on the ViewModel's scope would stay running, holding its last state, + * until the ViewModel itself was cleared. Main thread only. + * + * @param keep whether a cached key is still worth holding once [get] asks for another. + */ +class PageStateCache( + private val scope: CoroutineScope, + private val initial: S, + private val keep: (cached: K, requested: K) -> Boolean, + private val load: (K) -> Flow, +) { + private class Entry(val state: StateFlow, val job: Job) + + private val entries = HashMap>() + + /** The number of entries held, for tests. */ + internal val size: Int get() = entries.size + + fun get(key: K): StateFlow { + val iterator = entries.iterator() + while (iterator.hasNext()) { + val (cached, entry) = iterator.next() + if (cached != key && !keep(cached, key)) { + entry.job.cancel() + iterator.remove() + } + } + return entries.getOrPut(key) { + val job = SupervisorJob(scope.coroutineContext[Job]) + Entry( + state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial), + job = job, + ) + }.state + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt new file mode 100644 index 0000000..b932d7d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt @@ -0,0 +1,22 @@ +package de.jeanlucmakiola.calendula.ui.day + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.daysUntil +import kotlinx.datetime.plus + +/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */ +internal const val DAY_PAGE_COUNT: Int = 140_000 + +private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2 + +/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */ +private val PAGE_EPOCH = LocalDate(2000, 1, 1) + +/** The day shown on pager [page]. */ +internal fun dayForPage(page: Int): LocalDate = + PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY) + +/** The pager page showing [date]. */ +internal fun dayPageFor(date: LocalDate): Int = + (EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1) 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 49d6678..5bf6f7b 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 @@ -1,7 +1,22 @@ package de.jeanlucmakiola.calendula.ui.day import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.animateDpAsState +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.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.rememberCalendarPageSpec +import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -43,12 +58,10 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key -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.clip @@ -109,14 +122,11 @@ import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController import de.jeanlucmakiola.calendula.ui.common.startInstant 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.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.trimmedLines -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid @@ -137,7 +147,6 @@ import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock import java.util.Locale import kotlin.math.roundToInt @@ -145,6 +154,9 @@ import kotlin.math.roundToInt private val ALL_DAY_ROW_HEIGHT = 20.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp +/** Breathing room between the all-day strip and the scrolling timeline below. */ +private val TIMELINE_TOP_GAP = 8.dp + /** Total all-day strip height for the day (0 when there are no all-day events). */ internal fun DayUiState.Success.allDayStripHeight(): Dp { if (allDay.isEmpty()) return 0.dp @@ -169,7 +181,7 @@ fun DayScreen( viewModel: DayViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - val date by viewModel.date.collectAsStateWithLifecycle() + val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle() // When opened from the month grid, anchor to the tapped date. LaunchedEffect(initialDateIso) { @@ -179,36 +191,39 @@ fun DayScreen( val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - val isOnToday = when (val s = state) { - is DayUiState.Success -> s.date == s.today - 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 DayUiState.Success -> s.today.year - else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year - } - - // Slide direction for the day 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 DayUiState.Success -> if (s.today < s.date) -1 else 1 - else -> 0 + // Opens on the tapped date directly, rather than on today and then correcting. + val pagerState = rememberPagerState( + initialPage = initialDateIso?.let { dayPageFor(LocalDate.parse(it)) } ?: anchorPage, + ) { DAY_PAGE_COUNT } + val pageSpec = rememberCalendarPageSpec() + val reduceMotion = rememberReduceMotion() + // The pager leads and the anchor follows once it settles, so the anchor only + // moves on its own to correct the pager — e.g. re-entry from the month grid + // on another date. 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 lies on. + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled) + } + // The day under the finger, so the title turns over mid-swipe, not after it. + val date = dayForPage(pagerState.currentPage) + + // Off the ticking clock rather than the loaded day, so it turns over at + // midnight; derived, so the per-minute tick doesn't recompose the screen. + val nowState = rememberCurrentMinute() + val zone = remember { TimeZone.currentSystemDefault() } + val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } } + val isOnToday = state !is DayUiState.Success || date == today + + // 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 < date) -1 else 1 - viewModel.goToDate(target) + scope.launch { pagerState.jumpToPage(dayPageFor(target), reduceMotion, pageSpec) } } + val jumpToToday = { jumpToDate(today) } ModalNavigationDrawer( drawerState = drawerState, @@ -240,7 +255,7 @@ fun DayScreen( topBar = { DayTopBar( date = date, - currentYear = currentYear, + currentYear = today.year, selectedView = selectedView, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, quickSwitchViews = quickSwitchViews, @@ -262,10 +277,9 @@ fun DayScreen( ) { innerPadding -> DayContent( state = state, - slideDir = slideDir, - onSwipeNext = goNext, - onSwipePrev = goPrev, - onRetry = jumpToToday, + pagerState = pagerState, + day = viewModel::day, + onRetry = viewModel::goToToday, onEventClick = onEventClick, onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, modifier = Modifier @@ -279,19 +293,16 @@ fun DayScreen( @Composable private fun DayContent( state: DayUiState, - slideDir: Int, - onSwipeNext: () -> Unit, - onSwipePrev: () -> Unit, + pagerState: PagerState, + day: (LocalDate) -> StateFlow, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() - val reduceMotion = rememberReduceMotion() - // Hoisted above the per-day AnimatedContent so the vertical scroll position + // Shared by every page and the gutter, so the vertical scroll position // survives day-to-day swipes. We only centre on noon once, on first entry // into the day view (i.e. when arriving from the month/week view). val scrollState = rememberScrollState() @@ -302,59 +313,45 @@ private fun DayContent( scrollState.scrollTo(scrollState.maxValue / 2) } - // Single, hoisted all-day strip height — shared by the outgoing and incoming - // day during a swipe, so the strip slides along but never jumps in height. - val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp - val allDayHeight by animateDpAsState( - targetValue = targetAllDayHeight, - label = "day-all-day-strip-height", - ) - - // Whole-page horizontal swipe, one level above the timeline's vertical - // scroll: a horizontal drag crosses this detector's slop, while a vertical - // drag is consumed by the inner scroll first — the two gestures coexist. - 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 day handles that itself. AnimatedContent( targetState = state, - modifier = Modifier.fillMaxSize().then(swipeModifier), - contentKey = { s -> - when (s) { - is DayUiState.Success -> "success-${s.date}" - is DayUiState.Failure -> "failure-${s.reason}" - DayUiState.Loading -> "loading" - } - }, - transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) }, - label = "day-transition", + modifier = Modifier.fillMaxSize(), + contentKey = { it::class }, + transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, + label = "day-state", ) { s -> when (s) { DayUiState.Loading -> DayLoading() is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is DayUiState.Success -> DaySuccess( - state = s, + is DayUiState.Success -> DayPager( + pagerState = pagerState, + day = day, + today = s.today, + initialStripHeight = s.allDayStripHeight(), scrollState = scrollState, - allDayHeight = allDayHeight, dragController = dragController, onEventClick = onEventClick, 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, ) } } @@ -362,6 +359,101 @@ private fun DayContent( } } +/** + * The days as pages that follow the finger and snap once a swipe passes half a + * page or is flung (#336), beside a gutter that stays put. + */ +@Composable +private fun DayPager( + pagerState: PagerState, + day: (LocalDate) -> StateFlow, + today: LocalDate, + initialStripHeight: Dp, + scrollState: ScrollState, + dragController: TimelineDragController, + onEventClick: (EventInstance) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, +) { + val stripHeights = remember { mutableStateMapOf() } + val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight) + DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + // The days either side are composed ahead, so a swipe lands on a + // loaded day rather than watching it fill in. + beyondViewportPageCount = 1, + // A held block moves within its day; the page stays put under it. + userScrollEnabled = !dragController.isDragging, + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + snapAnimationSpec = rememberCalendarPageSpec(), + ), + ) { page -> + val date = dayForPage(page) + val pageState by remember(date) { day(date) }.collectAsStateWithLifecycle() + val loaded = pageState as? DayUiState.Success + // Until its day arrives a page shows the empty column. + val empty = remember(date, today) { + DayUiState.Success(date = date, today = today, allDay = emptyList(), timed = emptyList()) + } + val stripHeight = loaded?.allDayStripHeight() + DisposableEffect(page, stripHeight) { + if (stripHeight != null) stripHeights[page] = stripHeight + onDispose { stripHeights.remove(page) } + } + DayPage( + state = loaded ?: empty, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = page == pagerState.currentPage, + onEventClick = onEventClick, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } + } +} + +/** What stays put while the days swipe: the hour gutter, with [pages] filling the rest. */ +@Composable +private fun DayFrame( + allDayHeight: Dp, + scrollState: ScrollState, + dragController: TimelineDragController, + pages: @Composable () -> Unit, +) { + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) { + Spacer( + Modifier + .fillMaxWidth() + .height(allDayHeight) + .background(MaterialTheme.colorScheme.surface), + ) + Spacer(Modifier.height(TIMELINE_TOP_GAP)) + // Resolves the hour height off the same viewport height the pages' + // timelines have, so labels and column agree. + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val zoom = LocalTimelineZoom.current + val hourHeight = zoom.scale.hourHeight(maxHeight) + // Start inset so the labels centre on the top bar hamburger, + // matching the week view. + HourGutter( + scrollState = scrollState, + hourHeight = hourHeight, + dragController = dragController, + modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom), + ) + } + } + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() } + } +} + +/** A single day in its frame, without the pager — for the Settings preview. */ @Composable internal fun DaySuccess( state: DayUiState.Success, @@ -371,6 +463,37 @@ internal fun DaySuccess( onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, +) { + DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) { + DayPage( + state = state, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = true, + onEventClick = onEventClick, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } +} + +/** + * One day's page: all-day strip and column. + * + * @param active whether this is the page on screen, the only one that may + * publish the drag geometry. + */ +@Composable +private fun DayPage( + state: DayUiState.Success, + allDayHeight: Dp, + scrollState: ScrollState, + dragController: TimelineDragController, + active: Boolean, + onEventClick: (EventInstance) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { // All-day strip collapses to nothing when the day has no all-day events, @@ -389,13 +512,12 @@ internal fun DaySuccess( .background(MaterialTheme.colorScheme.surface), ) } - // 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, @@ -483,8 +605,6 @@ private fun AllDayStrip( end = TIMELINE_CONTENT_END_INSET, ), ) { - // Keep the gutter-width offset so the bars line up with the day column. - Spacer(Modifier.width(GUTTER_WIDTH)) // Bars are positioned absolutely by lane (vertical stacking); each spans // the full day-column width. clipToBounds keeps bars from spilling out // while the height animates. @@ -547,6 +667,7 @@ private fun Timeline( state: DayUiState.Success, scrollState: ScrollState, dragController: TimelineDragController, + active: Boolean, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, @@ -568,61 +689,53 @@ 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 column 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. - Row(modifier = Modifier.fillMaxSize().then(pinch)) { - // Hour gutter (scrolls in sync with the day column). Start inset so the - // labels centre on the top bar hamburger, matching the week view. - HourGutter( - scrollState = scrollState, + // 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. + 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 }, + ) { + DayColumnCard( + blocks = state.timed, + dark = dark, + date = state.date, + today = state.today, hourHeight = hourHeight, dragController = dragController, - ) - // Day column: rounded, clipped scroll viewport (permanent corners). - Box( + onEventClick = onEventClick, + onCreateAt = onCreateAt, + onDrop = onDrop, modifier = Modifier - .weight(1f) - .fillMaxHeight() - .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)) - .verticalScroll(scrollState) - .onGloballyPositioned { dragController.geometry.viewport = it }, - ) { - DayColumnCard( - blocks = state.timed, - dark = dark, - date = state.date, - today = state.today, - hourHeight = hourHeight, - dragController = dragController, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - modifier = Modifier - .fillMaxWidth() - .height(totalHeight) - // The scrolling content itself, so its root position - // already folds in the scroll offset. - .onGloballyPositioned { coords -> - dragController.geometry.let { - it.grid = coords - it.scroll = scrollState - it.hourPx = with(density) { hourHeight.toPx() } - it.blockInsetPx = blockInsetPx - it.columnGapPx = 0f - it.columnWidthPx = coords.size.width.toFloat() - it.days = listOf(state.date) - it.isRtl = isRtl - } - }, - ) - } + .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 + dragController.geometry.let { + it.grid = coords + it.scroll = scrollState + it.hourPx = with(density) { hourHeight.toPx() } + it.blockInsetPx = blockInsetPx + it.columnGapPx = 0f + it.columnWidthPx = coords.size.width.toFloat() + it.days = listOf(state.date) + it.isRtl = isRtl + } + }, + ) } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt index 4146aee..e310d49 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt @@ -9,6 +9,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.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutDay import kotlinx.coroutines.CoroutineDispatcher @@ -18,21 +19,25 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime -import kotlinx.datetime.minus -import kotlinx.datetime.plus +import kotlinx.datetime.daysUntil import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import kotlin.time.Instant import javax.inject.Inject +import kotlin.math.abs + +/** How far from the requested day [DayViewModel.day] keeps other days cached. */ +private const val DAY_CACHE_DAYS = 7 @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel @@ -47,32 +52,53 @@ class DayViewModel @Inject constructor( get() = Clock.System.now().toLocalDateTime(zone).date private val _date = MutableStateFlow(todayDate) - val date: StateFlow = _date + /** The pager page the anchor day sits on. */ + val anchorPage: StateFlow = _date + .map { dayPageFor(it) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = dayPageFor(todayDate), + ) + + /** + * The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]: + * moving the anchor keeps the last result until the new day arrives, since + * the pages draw their own days and this only gates the failure screen. + */ val state: StateFlow = _date - .flatMapLatest { day -> - val range = dayRange(day, zone) - combine( - repository.calendars(), - repository.instances(range), - ) { calendars, instances -> - buildState(day, calendars, instances) - } - } + .flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } } .catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = DayUiState.Loading, ) - fun goToPrev() { - _date.value = _date.value.minus(1, DateTimeUnit.DAY) + private val days = PageStateCache( + scope = viewModelScope, + initial = DayUiState.Loading, + // Far enough to cover the pages either side and a swipe back, so paging + // through a month doesn't keep a month of queries around. + keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS }, + ) { date -> + combine( + repository.calendars(), + repository.instances(dayRange(date, zone)), + ) { calendars, instances -> + buildState(date, calendars, instances) + } + .catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) } - fun goToNext() { - _date.value = _date.value.plus(1, DateTimeUnit.DAY) + /** The day [date], shared between its pager page and [state]. Main thread only. */ + fun day(date: LocalDate): StateFlow = days.get(date) + + /** The pager came to rest on [page]; follow it unless it is already the anchor's. */ + fun onPageSettled(page: Int) { + if (dayPageFor(_date.value) != page) _date.value = dayForPage(page) } fun goToToday() { 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 afcddc8..78a3933 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,8 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week import androidx.compose.animation.AnimatedContent -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 @@ -12,13 +10,12 @@ 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 de.jeanlucmakiola.calendula.ui.common.jumpToPage +import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight 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 @@ -252,13 +249,7 @@ fun WeekScreen( // hasn't caught up with the fling yet to tell the difference. val jumpToDate: (LocalDate) -> Unit = { target -> val page = weekPageFor(target, firstDay) - scope.launch { - if (reduceMotion) { - pagerState.scrollToPage(page) - } else { - pagerState.animateScrollToPage(page, animationSpec = pageSpec) - } - } + scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) } } val jumpToToday = { jumpToDate(today) } @@ -472,33 +463,6 @@ private fun WeekPager( } } -/** - * 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) } 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 9293c40..30c31e4 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 @@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.calendarListFailure 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.MutableStateFlow @@ -101,38 +102,33 @@ class WeekViewModel @Inject constructor( combine(_anchor, firstDayOfWeek) { anchor, ws -> anchor.startOfWeek(ws) } .distinctUntilChanged() .flatMapLatest { start -> week(start).filterNot { it is WeekUiState.Loading } } + .catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = WeekUiState.Loading, ) - 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, - ) + private val weeks = 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 -> + combine( + repository.calendars(), + repository.instances(weekRange(start, zone)), + ) { calendars, instances -> + buildState(start, 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 pager came to rest on [page]; follow it unless it is already the anchor's. */ fun onPageSettled(page: Int) { val ws = firstDayOfWeek.value diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt new file mode 100644 index 0000000..1266a71 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.math.abs + +@OptIn(ExperimentalCoroutinesApi::class) +class PageStateCacheTest { + + @Test + fun `the same key shares one state`() = runTest { + val cache = PageStateCache(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { flow { emit(it) } } + assertThat(cache.get(5)).isSameInstanceAs(cache.get(5)) + } + + @Test + fun `a dropped entry stops loading`() = runTest(UnconfinedTestDispatcher()) { + var running = 0 + val cache = PageStateCache(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { key -> + flow { + running++ + try { + emit(key) + awaitCancellation() + } finally { + running-- + } + } + } + backgroundScope.launch { cache.get(0).collect {} } + assertThat(running).isEqualTo(1) + + // Far enough away that 0 is let go, even though it is still collected. + cache.get(10) + assertThat(running).isEqualTo(0) + assertThat(cache.size).isEqualTo(1) + } + + @Test + fun `nearby entries are kept`() = runTest { + val cache = PageStateCache(backgroundScope, 0, { a, b -> abs(a - b) <= 2 }) { flow { emit(it) } } + cache.get(4) + cache.get(5) + cache.get(6) + assertThat(cache.size).isEqualTo(3) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt new file mode 100644 index 0000000..ac37f9b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt @@ -0,0 +1,22 @@ +package de.jeanlucmakiola.calendula.ui.day + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class DayPagingTest { + + @Test + fun `a page and its day round-trip`() { + listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day -> + assertThat(dayForPage(dayPageFor(day))).isEqualTo(day) + } + } + + @Test + fun `adjacent pages are adjacent days across a month end`() { + val page = dayPageFor(LocalDate(2026, 2, 28)) + assertThat(dayForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1)) + assertThat(dayForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27)) + } +}