From 7c17bf2ff4d9e6a8e0d196f45e2d48808663b683 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 25 Sep 2026 13:41:28 +0200 Subject: [PATCH 01/17] Page the day view with a pager that follows the finger (#336) Same model as the week view: days slide with the finger and snap past half a page or on a fling, the hour gutter stays put, and the neighbouring days are loaded ahead. The strip-height blend moves to common so both views share it. --- .../calendula/ui/common/CalendarPager.kt | 55 +++ .../calendula/ui/day/DayPaging.kt | 22 + .../calendula/ui/day/DayScreen.kt | 391 +++++++++++------- .../calendula/ui/day/DayViewModel.kt | 68 ++- .../calendula/ui/week/WeekScreen.kt | 42 +- .../calendula/ui/day/DayPagingTest.kt | 22 + 6 files changed, 403 insertions(+), 197 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPager.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.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 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/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..1ead9f5 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 @@ -18,21 +18,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 +51,58 @@ 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) - } - } - .catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) + .flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = DayUiState.Loading, ) - fun goToPrev() { - _date.value = _date.value.minus(1, DateTimeUnit.DAY) + private val days = HashMap>() + + /** + * The day [date], shared between its pager page and [state]. Days far from + * the one asked for are dropped, so paging through a month doesn't keep a + * month of queries around. Main thread only. + */ + fun day(date: LocalDate): StateFlow { + days.keys.removeAll { abs(it.daysUntil(date)) > DAY_CACHE_DAYS } + return days.getOrPut(date) { + combine( + repository.calendars(), + repository.instances(dayRange(date, zone)), + ) { calendars, instances -> + buildState(date, calendars, instances) + } + .catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = DayUiState.Loading, + ) + } } - fun goToNext() { - _date.value = _date.value.plus(1, DateTimeUnit.DAY) + /** 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/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)) + } +} From eda616b42ec3530cf55af0f6afc6bc3bd4bd363f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 25 Sep 2026 14:35:50 +0200 Subject: [PATCH 02/17] Cancel dropped page states and restore the failure catch (#336) Evicted week and day states kept their sharing coroutine, and their last loaded events, alive until the view was closed; each now shares on its own job, cancelled when it is let go. --- .../calendula/ui/common/PageStateCache.kt | 53 +++++++++++++++++++ .../calendula/ui/day/DayViewModel.kt | 42 +++++++-------- .../calendula/ui/week/WeekViewModel.kt | 42 +++++++-------- .../calendula/ui/common/PageStateCacheTest.kt | 53 +++++++++++++++++++ 4 files changed, 144 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt 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/DayViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt index 1ead9f5..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 @@ -68,38 +69,33 @@ class DayViewModel @Inject constructor( */ val state: StateFlow = _date .flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } } + .catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), initialValue = DayUiState.Loading, ) - private val days = HashMap>() - - /** - * The day [date], shared between its pager page and [state]. Days far from - * the one asked for are dropped, so paging through a month doesn't keep a - * month of queries around. Main thread only. - */ - fun day(date: LocalDate): StateFlow { - days.keys.removeAll { abs(it.daysUntil(date)) > DAY_CACHE_DAYS } - return days.getOrPut(date) { - combine( - repository.calendars(), - repository.instances(dayRange(date, zone)), - ) { calendars, instances -> - buildState(date, calendars, instances) - } - .catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = DayUiState.Loading, - ) + 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) } + /** 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) 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) + } +} From a2b6793406ae60005acefff6eb943186c7a159a0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 25 Sep 2026 14:40:04 +0200 Subject: [PATCH 03/17] 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 33c61202d9a699a701b7801a820fb31cecef5052 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 25 Sep 2026 21:25:17 +0200 Subject: [PATCH 04/17] 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)) + } } From f7805cb0b50d0cd0fdaf5acc4c911f382e7bbbbb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 11:21:32 +0200 Subject: [PATCH 05/17] Morph events between views instead of fading (#184) --- .../calendula/ui/CalendarHost.kt | 44 +++++-- .../calendula/ui/agenda/AgendaScreen.kt | 4 +- .../calendula/ui/common/ViewMorph.kt | 115 ++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 28 +++-- .../calendula/ui/month/MonthScreen.kt | 45 ++++++- .../calendula/ui/settings/SettingsScreen.kt | 19 +-- .../calendula/ui/week/WeekScreen.kt | 34 ++++-- .../calendula/ui/common/ViewMorphKeyTest.kt | 50 ++++++++ docs/ARCHITECTURE.md | 5 + floret-kit | 2 +- 10 files changed, 301 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt 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 5f445ac..887e615 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -3,10 +3,10 @@ package de.jeanlucmakiola.calendula.ui import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -27,12 +27,18 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.floret.identity.fadeThrough +import de.jeanlucmakiola.floret.identity.predictiveBackExit +import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars +import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel +import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.selectView @@ -72,6 +78,7 @@ import kotlin.time.Clock * as [WidgetNavRequest.OpenEvent], which also roots the back stack in the * widget's view.) */ +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun CalendarHost( modifier: Modifier = Modifier, @@ -341,6 +348,9 @@ fun CalendarHost( } val slideSpec = rememberCalendarSlideSpec() + // Overlays leave from the predictive-back preview (#184): shrinking on from + // where the gesture let go instead of snapping back and sliding off. + val overlayExit = predictiveBackExit() // Base-level back: pop the view stack while no overlay covers it (each overlay // owns its own BackHandler and takes precedence). Disabled at the home view, @@ -355,19 +365,31 @@ fun CalendarHost( Box(modifier = modifier.fillMaxSize()) { // Switching between the peer views (month/week/day/agenda) is lateral // navigation, so it fades through rather than sliding — paging *within* a - // view keeps the directional slide. AnimatedContent keyed on the view type. + // view keeps the directional slide. What both views show morphs across + // (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade. val viewSwitch = fadeThrough() + val reduceMotion = rememberReduceMotion() + val morphBounds = rememberViewMorphBoundsTransform() + val morphFade = rememberCalendarFadeSpec() CompositionLocalProvider( LocalEventMove provides moveScope, // The failure state's way out when every calendar is switched off // (#239); nothing else in the calendar surfaces navigates here. LocalManageCalendars provides { showCalendars = true }, ) { + SharedTransitionLayout(Modifier.fillMaxSize()) { AnimatedContent( targetState = view, transitionSpec = { viewSwitch }, label = "view-switch", ) { currentView -> + CompositionLocalProvider( + LocalViewMorph provides if (reduceMotion) { + null + } else { + ViewMorphScope(this@SharedTransitionLayout, this@AnimatedContent, morphBounds, morphFade) + }, + ) { when (currentView) { CalendarView.Week -> WeekScreen( selectedView = currentView, @@ -430,6 +452,8 @@ fun CalendarHost( todayInToolbar = todayInToolbar, ) } + } + } } } @@ -442,7 +466,7 @@ fun CalendarHost( AnimatedVisibility( visible = showSearch, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { SearchScreen( onBack = { showSearch = false }, @@ -455,7 +479,7 @@ fun CalendarHost( AnimatedVisibility( visible = detailKey != null, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { activeKey?.let { key -> EventDetailScreen( @@ -483,7 +507,7 @@ fun CalendarHost( AnimatedVisibility( visible = createDateIso != null, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { (createDateIso ?: heldCreateIso)?.let { iso -> EventEditScreen( @@ -500,7 +524,7 @@ fun CalendarHost( AnimatedVisibility( visible = editKey != null, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { (editKey ?: heldEditKey)?.let { key -> EventEditScreen( @@ -520,7 +544,7 @@ fun CalendarHost( AnimatedVisibility( visible = showSettings, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { SettingsScreen( onBack = { showSettings = false }, @@ -577,7 +601,7 @@ fun CalendarHost( AnimatedVisibility( visible = showCalendars, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { CalendarsScreen( onBack = { showCalendars = false }, @@ -589,7 +613,7 @@ fun CalendarHost( AnimatedVisibility( visible = showBackup, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { // Settings can open this screen without the manager underneath, so // its failure states' "Manage calendars" way out has to open the diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index bb4ef71..ca64ec5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -1,5 +1,7 @@ package de.jeanlucmakiola.calendula.ui.agenda +import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey +import de.jeanlucmakiola.calendula.ui.common.morphEvent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -420,7 +422,7 @@ private fun AgendaList( zone = zone, position = positionOf(index, day.events.size), dimmed = dimPast && event.hasEnded(now), - modifier = animateItemMotion(), + modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)), onClick = { onEventClick(event) }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt new file mode 100644 index 0000000..503cc32 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -0,0 +1,115 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate + +/** + * What the views share when switching between them (#184): an event visible in + * both grows from where it sat into where it lands, a date label travels to its + * new seat, and everything else fades through. + * + * An element is tagged with the day it is drawn *from*, not just its instance: a + * multi-day event is drawn once per day, per week row or per timeline column, + * and only one element per key may take part. The segment that starts on the + * same day in both views is the one that travels; the rest fade where they are. + */ +sealed interface ViewMorphKey { + data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey + + data class Day(val date: LocalDate) : ViewMorphKey + + companion object { + /** [event]'s occurrence as drawn from [day]. */ + fun event(event: EventInstance, day: LocalDate): Event = + Event(event.eventId, event.start.toEpochMilliseconds(), day) + } +} + +/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */ +@OptIn(ExperimentalSharedTransitionApi::class) +class ViewMorphScope( + val shared: SharedTransitionScope, + val visibility: AnimatedVisibilityScope, + val boundsTransform: BoundsTransform, + val fadeSpec: FiniteAnimationSpec, +) + +val LocalViewMorph = compositionLocalOf { null } + +/** + * Whether a pager page may carry the morph tags: only the settled one. Its + * neighbours are composed ahead and repeat some of its dates, which would both + * claim its keys and fly in from off screen. + */ +fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage + +/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */ +@Composable +fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) { + val morph = LocalViewMorph.current + CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content) +} + +/** + * Tags an event chip, bar, block or row. Its bounds are shared and the two faces + * cross-fade inside them; remeasured rather than scaled, so a title keeps its + * real size and is clipped while there is no room, instead of smearing. + * + * Goes after the element's offset and before its size, so the travelling bounds + * drive the measurement. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier { + val morph = LocalViewMorph.current ?: return this + return with(morph.shared) { + this@morphEvent.sharedBounds( + sharedContentState = rememberSharedContentState(key), + animatedVisibilityScope = morph.visibility, + enter = fadeIn(morph.fadeSpec), + exit = fadeOut(morph.fadeSpec), + boundsTransform = morph.boundsTransform, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + zIndexInOverlay = 1f, + ) + } +} + +/** Tags a day's number, which is small on both sides, so it scales on its way. */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun Modifier.morphDay(date: LocalDate): Modifier { + val morph = LocalViewMorph.current ?: return this + return with(morph.shared) { + this@morphDay.sharedBounds( + sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date)), + animatedVisibilityScope = morph.visibility, + enter = fadeIn(morph.fadeSpec), + exit = fadeOut(morph.fadeSpec), + boundsTransform = morph.boundsTransform, + ) + } +} + +/** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */ +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun rememberViewMorphBoundsTransform(): BoundsTransform { + val spec = MaterialTheme.motionScheme.defaultSpatialSpec() + return remember(spec) { BoundsTransform { _, _ -> spec } } +} 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 8adc2f6..6c817dc 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,6 +12,10 @@ 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.ViewMorphKey +import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage +import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.pagedHeight import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute @@ -403,16 +407,18 @@ private fun DayPager( 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, - ) + ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { + DayPage( + state = loaded ?: empty, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = page == pagerState.currentPage, + onEventClick = onEventClick, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } } } } @@ -622,6 +628,7 @@ private fun AllDayStrip( onClick = { onEventClick(span.event) }, modifier = Modifier .offset(y = ALL_DAY_ROW_HEIGHT * span.lane) + .morphEvent(ViewMorphKey.event(span.event, state.date)) .width(barWidth) .height(ALL_DAY_ROW_HEIGHT) .padding(horizontal = 1.dp, vertical = 1.dp), @@ -844,6 +851,7 @@ private fun DayColumnCard( onDrop = onDrop, modifier = Modifier .offset(x = place.x, y = place.y + topInset) + .morphEvent(ViewMorphKey.event(block.event, date)) .width(place.width) .height(blockHeight) .padding(horizontal = BLOCK_OUTER_INSET), 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 348ada3..cfc7fc8 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 @@ -10,6 +10,11 @@ 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.LocalViewMorph +import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey +import de.jeanlucmakiola.calendula.ui.common.morphDay +import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.pagedHeight import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec @@ -669,7 +674,7 @@ private fun MonthPager( ) { if (static) { Box(modifier) { - MonthPage(pagerState.currentPage, true, weekStart, monthState, loading, page) + MonthPage(pagerState.currentPage, true, true, weekStart, monthState, loading, page) } return } @@ -688,7 +693,15 @@ private fun MonthPager( snapAnimationSpec = rememberCalendarPageSpec(), ), ) { index -> - MonthPage(index, index == pagerState.currentPage, weekStart, monthState, loading, page) + MonthPage( + index = index, + active = index == pagerState.currentPage, + morphActive = morphPageActive(index, pagerState.settledPage), + weekStart = weekStart, + monthState = monthState, + loading = loading, + page = page, + ) } } @@ -696,6 +709,7 @@ private fun MonthPager( private fun MonthPage( index: Int, active: Boolean, + morphActive: Boolean, weekStart: DayOfWeek, monthState: (YearMonth, DayOfWeek) -> StateFlow, loading: @Composable () -> Unit, @@ -705,9 +719,11 @@ private fun MonthPage( val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle() val chipDrag = LocalMonthDrag.current val morph = LocalMonthMorph.current + val viewMorph = LocalViewMorph.current CompositionLocalProvider( LocalMonthDrag provides chipDrag.takeIf { active }, LocalMonthMorph provides morph.takeIf { active }, + LocalViewMorph provides viewMorph.takeIf { morphActive }, ) { when (val s = state) { is MonthUiState.Success -> page(index, s) @@ -1334,6 +1350,10 @@ private fun SplitMonthContent( // its month still for the length of the cross-fade. val leaving = transition.targetState == EnterExitState.PostExit CompositionLocalProvider( + // The split style morphs within itself and so sits the view + // switch's morph out (#184): one element can't travel in two + // shared transitions at once. + LocalViewMorph provides null, // Null under reduced motion: nothing is tagged, nothing // travels, and the cross-fade above is the whole transition. LocalMonthMorph provides if (reduceMotion) { @@ -2254,6 +2274,17 @@ private fun MonthWeekRow( instanceId = span.event.instanceId, ), ) + // A blanked day is drawn again in its own + // month's block, which owns the tag. + .then( + week.days[span.startCol].let { start -> + if (blankOutside && !inMonth(start)) { + Modifier + } else { + Modifier.morphEvent(ViewMorphKey.event(span.event, start)) + } + }, + ) .width(colW * cols) .height(EVENT_ROW_HEIGHT) .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), @@ -2311,6 +2342,13 @@ private fun MonthWeekRow( y = EVENT_ROW_HEIGHT * freeSlots[i], ) .morphBounds(MonthMorphKey.Event(d, ev.instanceId)) + .then( + if (blankOutside && !inMonth(d)) { + Modifier + } else { + Modifier.morphEvent(ViewMorphKey.event(ev, d)) + }, + ) .width(colW) .height(EVENT_ROW_HEIGHT) .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), @@ -2576,6 +2614,7 @@ private fun DayNumberCell( if (isToday) { Box( modifier = Modifier + .morphDay(date) .size(DAY_NUMBER_HEIGHT) .background(MaterialTheme.colorScheme.primary, CircleShape), contentAlignment = Alignment.Center, @@ -2596,6 +2635,7 @@ private fun DayNumberCell( maxLines = 1, overflow = TextOverflow.Visible, softWrap = false, + modifier = Modifier.morphDay(date), ) } else { Text( @@ -2603,6 +2643,7 @@ private fun DayNumberCell( style = MaterialTheme.typography.labelMedium, color = if (inMonth) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.morphDay(date), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index eddae09..541a367 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -66,6 +64,7 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.openIssueTracker import de.jeanlucmakiola.floret.crash.submitCrashReport +import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.locale.AppLanguage /** The settings sub-screens reached from the hub's category rows. */ @@ -90,6 +89,8 @@ fun SettingsScreen( val state by viewModel.state.collectAsStateWithLifecycle() var section by rememberSaveable { mutableStateOf(null) } val slideSpec = rememberCalendarSlideSpec() + // Leaves from the predictive-back preview rather than sliding off at full size. + val overlayExit = predictiveBackExit() Box( modifier = modifier @@ -106,28 +107,28 @@ fun SettingsScreen( AnimatedVisibility( visible = section == SettingsSection.Appearance, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) } AnimatedVisibility( visible = section == SettingsSection.Views, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }) } AnimatedVisibility( visible = section == SettingsSection.EventForm, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }) } AnimatedVisibility( visible = section == SettingsSection.Notifications, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { NotificationsScreen( state = state, @@ -139,21 +140,21 @@ fun SettingsScreen( AnimatedVisibility( visible = section == SettingsSection.SpecialDates, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }) } AnimatedVisibility( visible = section == SettingsSection.Widgets, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }) } AnimatedVisibility( visible = section == SettingsSection.Licences, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { LicencesScreen(onBack = { section = null }) } 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 855d5fc..b9e9b80 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 @@ -135,6 +135,11 @@ 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.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey +import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage +import de.jeanlucmakiola.calendula.ui.common.morphDay +import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale @@ -525,17 +530,19 @@ private fun WeekPager( 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, - ) + ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { + WeekPage( + state = loaded ?: empty, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = page == pagerState.currentPage, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } } } } @@ -776,7 +783,7 @@ private fun WeekDayHeader( shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.morphDay(date).fillMaxSize(), ) { Box(contentAlignment = Alignment.Center) { Text( @@ -790,6 +797,7 @@ private fun WeekDayHeader( Text( text = date.day.toString(), style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.morphDay(date), ) } } @@ -886,6 +894,7 @@ private fun AllDayStrip( x = colWidth * span.startCol, y = ALL_DAY_ROW_HEIGHT * span.lane, ) + .morphEvent(ViewMorphKey.event(span.event, state.days[span.startCol])) .width(colWidth * spanCols) .height(ALL_DAY_ROW_HEIGHT) .padding(horizontal = 1.dp, vertical = 1.dp), @@ -1120,6 +1129,7 @@ private fun DayColumnCard( onDrop = onDrop, modifier = Modifier .offset(x = place.x, y = place.y + topInset) + .morphEvent(ViewMorphKey.event(block.event, date)) .width(place.width) .height(blockHeight) .padding(horizontal = BLOCK_OUTER_INSET), diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt new file mode 100644 index 0000000..b0c5a29 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt @@ -0,0 +1,50 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class ViewMorphKeyTest { + + private fun instance(instanceId: Long, eventId: Long = 7, startMillis: Long = 1_000L) = EventInstance( + instanceId = instanceId, + eventId = eventId, + calendarId = 1, + title = "Standup", + start = Instant.fromEpochMilliseconds(startMillis), + end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L), + isAllDay = false, + color = 0, + location = null, + ) + + private val day = LocalDate(2026, 9, 25) + + @Test + fun `the same occurrence on the same day matches across views`() { + // Each view reads its own instances query, so the row id is no anchor. + assertThat(ViewMorphKey.event(instance(instanceId = 1), day)) + .isEqualTo(ViewMorphKey.event(instance(instanceId = 99), day)) + } + + @Test + fun `a multi-day event keys each day it is drawn from apart`() { + assertThat(ViewMorphKey.event(instance(1), day)) + .isNotEqualTo(ViewMorphKey.event(instance(1), LocalDate(2026, 9, 26))) + } + + @Test + fun `two occurrences of one series stay apart`() { + assertThat(ViewMorphKey.event(instance(1, startMillis = 1_000L), day)) + .isNotEqualTo(ViewMorphKey.event(instance(2, startMillis = 2_000L), day)) + } + + @Test + fun `only the settled page morphs`() { + assertThat(morphPageActive(page = 5, settledPage = 5)).isTrue() + assertThat(morphPageActive(page = 4, settledPage = 5)).isFalse() + assertThat(morphPageActive(page = 6, settledPage = 5)).isFalse() + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed3afde..6431da6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -83,6 +83,11 @@ gates on the first-launch wizard (`ui/onboarding/`), then shows plus overlay state for detail, edit, and settings — full-screen overlays driven by `AnimatedVisibility` with a *held-key* pattern: the last shown key stays alive through the slide-out so content never flashes empty. +The views themselves switch inside a `SharedTransitionLayout`: an event shown +in both morphs from its old bounds to its new ones, keyed per occurrence *and* +per day it is drawn from (`ViewMorphKey`), and only a pager's settled page +carries the tags. Overlays leave through floret-kit's `predictiveBackExit`, so +a committed back gesture finishes from its scaled preview. A tapped reminder notification routes through `MainActivity` (`singleTop` + `onNewIntent`) as an external detail key that `CalendarHost` consumes exactly like an event tap. diff --git a/floret-kit b/floret-kit index 14c13ba..1ab4689 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 14c13bae6d846845791e8b32eca6d016d2524364 +Subproject commit 1ab46897c30c663b2d590f26104ce8254a0be1df From dc841a91e0da60f564b38725a4d75fc59fea1ba0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 12:01:41 +0200 Subject: [PATCH 06/17] Remember the view morph scope across recompositions (#184) --- .../de/jeanlucmakiola/calendula/ui/CalendarHost.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 887e615..22ff57b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -383,13 +383,12 @@ fun CalendarHost( transitionSpec = { viewSwitch }, label = "view-switch", ) { currentView -> - CompositionLocalProvider( - LocalViewMorph provides if (reduceMotion) { - null - } else { - ViewMorphScope(this@SharedTransitionLayout, this@AnimatedContent, morphBounds, morphFade) - }, - ) { + val sharedScope = this@SharedTransitionLayout + val visibilityScope = this@AnimatedContent + val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade) { + if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade) + } + CompositionLocalProvider(LocalViewMorph provides viewMorph) { when (currentView) { CalendarView.Week -> WeekScreen( selectedView = currentView, From df33eca5f2edfa047283bc7993fc2981105afef6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 12:06:45 +0200 Subject: [PATCH 07/17] Reset the back preview when an overlay reopens (#184) --- .../calendula/ui/CalendarHost.kt | 7 ++++++ .../calendula/ui/calendars/BackupScreen.kt | 2 ++ .../calendula/ui/calendars/CalendarsScreen.kt | 4 ++++ .../calendula/ui/detail/EventDetailScreen.kt | 3 ++- .../calendula/ui/edit/EventEditScreen.kt | 3 ++- .../calendula/ui/imports/ImportScreen.kt | 3 ++- .../calendula/ui/licences/LicencesScreen.kt | 3 ++- .../calendula/ui/search/SearchScreen.kt | 3 ++- .../ui/settings/AppearanceSettings.kt | 2 ++ .../ui/settings/EventFormSettings.kt | 2 ++ .../ui/settings/NotificationSettings.kt | 2 ++ .../calendula/ui/settings/SettingsScreen.kt | 23 +++++++++++++------ .../ui/settings/SpecialDatesSettings.kt | 2 ++ .../calendula/ui/settings/ViewsSettings.kt | 2 ++ .../calendula/ui/settings/WidgetSettings.kt | 2 ++ floret-kit | 2 +- 16 files changed, 52 insertions(+), 13 deletions(-) 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 22ff57b..267ab39 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -470,6 +470,7 @@ fun CalendarHost( SearchScreen( onBack = { showSearch = false }, onEventClick = onEventClick, + visible = showSearch, ) } @@ -498,6 +499,7 @@ fun CalendarHost( importForm = form detailKey = null }, + visible = detailKey != null, ) } } @@ -515,6 +517,7 @@ fun CalendarHost( onClose = { createDateIso = null }, onSaved = { createDateIso = null }, onManageCalendars = { showCalendars = true }, + visible = createDateIso != null, ) } } @@ -535,6 +538,7 @@ fun CalendarHost( detailKey = null }, onManageCalendars = { showCalendars = true }, + visible = editKey != null, ) } } @@ -549,6 +553,7 @@ fun CalendarHost( onBack = { showSettings = false }, onManageCalendars = { showCalendars = true }, onOpenBackup = { showBackup = true }, + visible = showSettings, ) } @@ -605,6 +610,7 @@ fun CalendarHost( CalendarsScreen( onBack = { showCalendars = false }, onOpenBackup = { showBackup = true }, + visible = showCalendars, ) } @@ -639,6 +645,7 @@ fun CalendarHost( showBackup = false showCalendars = false }, + visible = showBackup, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index 7a3af49..2da55f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -82,6 +82,7 @@ private val RESTORE_MIME_TYPES = arrayOf( fun BackupScreen( onBack: () -> Unit, onImport: (Uri) -> Unit, + visible: Boolean = true, viewModel: CalendarsViewModel = hiltViewModel(), ) { val state by viewModel.backupState.collectAsStateWithLifecycle() @@ -137,6 +138,7 @@ fun BackupScreen( scrollable = state is BackupUiState.Ready, snackbarHost = { SnackbarHost(snackbarHostState) }, predictiveBack = true, + visible = visible, ) { when (val s = state) { BackupUiState.Loading -> BackupLoading() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 7d00c20..1c3f66f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -133,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE fun CalendarsScreen( onBack: () -> Unit, onOpenBackup: () -> Unit, + visible: Boolean = true, viewModel: CalendarsViewModel = hiltViewModel(), ) { val calendars by viewModel.calendars.collectAsStateWithLifecycle() @@ -177,6 +178,7 @@ fun CalendarsScreen( onConsumeError = viewModel::consumeError, onOpenBackup = onOpenBackup, onBack = onBack, + visible = visible, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, onSetVisible = viewModel::setCalendarVisible, @@ -193,6 +195,7 @@ private fun CalendarsList( onConsumeError: () -> Unit, onOpenBackup: () -> Unit, onBack: () -> Unit, + visible: Boolean, onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, onSetVisible: (Long, Boolean) -> Unit, @@ -218,6 +221,7 @@ private fun CalendarsList( onBack = onBack, snackbarHost = { SnackbarHost(snackbarHostState) }, predictiveBack = true, + visible = visible, ) { // What the per-calendar / per-account switches below actually do. HintText(stringResource(R.string.calendars_visibility_hint)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 2360edd..db61df9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -140,6 +140,7 @@ fun EventDetailScreen( onBack: () -> Unit, onEdit: () -> Unit, onDuplicate: (EventForm) -> Unit, + visible: Boolean = true, viewModel: EventDetailViewModel = hiltViewModel(), ) { LaunchedEffect(eventId, beginMillis, endMillis) { @@ -227,7 +228,7 @@ fun EventDetailScreen( } Scaffold( - modifier = Modifier.predictiveBack(onBack = onBack), + modifier = Modifier.predictiveBack(onBack = onBack, visible = visible), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index f591797..cc1a8bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -203,6 +203,7 @@ fun EventEditScreen( initialForm: EventForm? = null, initialFormSource: ImportSource = ImportSource.File, onManageCalendars: (() -> Unit)? = null, + visible: Boolean = true, viewModel: EventEditViewModel = hiltViewModel(), ) { LaunchedEffect(initialDateIso, editKey, initialForm) { @@ -285,7 +286,7 @@ fun EventEditScreen( } Scaffold( - modifier = Modifier.predictiveBack(onBack = close), + modifier = Modifier.predictiveBack(onBack = close, visible = visible), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index fcf33d3..8205465 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -78,6 +78,7 @@ fun ImportScreen( onOpenSingle: (EventForm) -> Unit, forceMany: Boolean = false, onManageCalendars: (() -> Unit)? = null, + visible: Boolean = true, viewModel: ImportViewModel = hiltViewModel(), ) { // hiltViewModel() resolves to the Activity's store and is retained across @@ -103,7 +104,7 @@ fun ImportScreen( Scaffold( modifier = Modifier - .predictiveBack(onBack = onClose) + .predictiveBack(onBack = onClose, visible = visible) .fillMaxSize(), topBar = { TopAppBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt index da70e2d..8e4674f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt @@ -23,13 +23,14 @@ import de.jeanlucmakiola.floret.components.positionOf * what the *recipient of the binary* is told. Each row opens the project's source. */ @Composable -internal fun LicencesScreen(onBack: () -> Unit) { +internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean = true) { val uriHandler = LocalUriHandler.current CollapsingScaffold( title = stringResource(R.string.licences_title), onBack = onBack, predictiveBack = true, + visible = visible, ) { Text( stringResource(R.string.licences_intro), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 2dde1ab..8453905 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -126,6 +126,7 @@ fun SearchScreen( onBack: () -> Unit, onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, + visible: Boolean = true, viewModel: SearchViewModel = hiltViewModel(), ) { val query by viewModel.query.collectAsStateWithLifecycle() @@ -184,7 +185,7 @@ fun SearchScreen( BackHandler(enabled = inSelection) { viewModel.clearSelection() } Scaffold( - modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection), + modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible), containerColor = MaterialTheme.colorScheme.surface, topBar = { SearchTopBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt index 40f1704..5774e5a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt @@ -76,6 +76,7 @@ internal fun AppearanceScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showTheme by remember { mutableStateOf(false) } var showBrandFont by remember { mutableStateOf(false) } @@ -96,6 +97,7 @@ internal fun AppearanceScreen( title = stringResource(R.string.settings_section_appearance), onBack = onBack, predictiveBack = true, + visible = visible, ) { // Theme & colour GroupedRow( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt index a9674aa..f7d0b39 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt @@ -41,6 +41,7 @@ internal fun EventFormScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showDefaultDuration by remember { mutableStateOf(false) } // The calendar whose duration-override picker is open, if any. @@ -53,6 +54,7 @@ internal fun EventFormScreen( title = stringResource(R.string.settings_section_event_form), onBack = onBack, predictiveBack = true, + visible = visible, ) { SettingsHint(stringResource(R.string.settings_form_fields_hint)) Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index cc77805..7fe559f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -63,6 +63,7 @@ internal fun NotificationsScreen( viewModel: SettingsViewModel, onBack: () -> Unit, onOpenSpecialDates: () -> Unit, + visible: Boolean = true, ) { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( @@ -92,6 +93,7 @@ internal fun NotificationsScreen( title = stringResource(R.string.settings_section_notifications), onBack = onBack, predictiveBack = true, + visible = visible, ) { GroupedRow( title = stringResource(R.string.settings_reminders), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 541a367..a24d9e3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -84,6 +84,7 @@ fun SettingsScreen( onManageCalendars: () -> Unit, onOpenBackup: () -> Unit, modifier: Modifier = Modifier, + visible: Boolean = true, viewModel: SettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -102,6 +103,7 @@ fun SettingsScreen( onOpenSection = { section = it }, onManageCalendars = onManageCalendars, onOpenBackup = onOpenBackup, + visible = visible, ) AnimatedVisibility( @@ -109,21 +111,21 @@ fun SettingsScreen( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = overlayExit, ) { - AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) + AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Appearance) } AnimatedVisibility( visible = section == SettingsSection.Views, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = overlayExit, ) { - ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }) + ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Views) } AnimatedVisibility( visible = section == SettingsSection.EventForm, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = overlayExit, ) { - EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }) + EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.EventForm) } AnimatedVisibility( visible = section == SettingsSection.Notifications, @@ -135,6 +137,7 @@ fun SettingsScreen( viewModel = viewModel, onBack = { section = null }, onOpenSpecialDates = { section = SettingsSection.SpecialDates }, + visible = section == SettingsSection.Notifications, ) } AnimatedVisibility( @@ -142,21 +145,21 @@ fun SettingsScreen( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = overlayExit, ) { - SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }) + SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.SpecialDates) } AnimatedVisibility( visible = section == SettingsSection.Widgets, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = overlayExit, ) { - WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }) + WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets) } AnimatedVisibility( visible = section == SettingsSection.Licences, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = overlayExit, ) { - LicencesScreen(onBack = { section = null }) + LicencesScreen(onBack = { section = null }, visible = section == SettingsSection.Licences) } } } @@ -171,8 +174,14 @@ private fun SettingsHub( onOpenSection: (SettingsSection) -> Unit, onManageCalendars: () -> Unit, onOpenBackup: () -> Unit, + visible: Boolean, ) { - CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) { + CollapsingScaffold( + title = stringResource(R.string.settings_title), + onBack = onBack, + predictiveBack = true, + visible = visible, + ) { // Card and support row are one grouped block, so the call to action // continues the container instead of sitting inside it as a button. Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt index 8933214..0e8559b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt @@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride internal fun SpecialDatesScreen( viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { val state by viewModel.specialDatesState.collectAsStateWithLifecycle() val settings by viewModel.state.collectAsStateWithLifecycle() @@ -76,6 +77,7 @@ internal fun SpecialDatesScreen( title = stringResource(R.string.settings_section_special_dates), onBack = onBack, predictiveBack = true, + visible = visible, ) { // Paused banner: the permission was revoked after enabling. if (state.enabled && state.stalledPermission) { 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 2f26da4..827cd07 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 @@ -65,6 +65,7 @@ internal fun ViewsScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showMonthStyle by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) } @@ -79,6 +80,7 @@ internal fun ViewsScreen( title = stringResource(R.string.settings_section_views), onBack = onBack, predictiveBack = true, + visible = visible, ) { val config = state.quickSwitchConfig diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt index 8aa6e34..d8301cc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -42,6 +42,7 @@ internal fun WidgetsScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showAgendaWidgetRange by remember { mutableStateOf(false) } var showWidgetSize by remember { mutableStateOf(false) } @@ -50,6 +51,7 @@ internal fun WidgetsScreen( title = stringResource(R.string.settings_section_widgets), onBack = onBack, predictiveBack = true, + visible = visible, ) { SettingsHint(stringResource(R.string.settings_widgets_hint)) Spacer(Modifier.height(8.dp)) diff --git a/floret-kit b/floret-kit index 1ab4689..1d6b1ed 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 1ab46897c30c663b2d590f26104ce8254a0be1df +Subproject commit 1d6b1ed2a9c1ebafae3d84df8ae33c775e484eef From 57dcda5b542423a62d8dba6b07aa0add001c8cc1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 12:11:10 +0200 Subject: [PATCH 08/17] Spring overlays back after a cancelled back gesture (#184) --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index 1d6b1ed..b7e73ee 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 1d6b1ed2a9c1ebafae3d84df8ae33c775e484eef +Subproject commit b7e73ee880449ade8d6162b56654083887fe669b From 39ca7d869a9625e5a98b74aaf7c5cf1763ebddc2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:10:49 +0200 Subject: [PATCH 09/17] Carry the timeline hours between views (#184) --- .../calendula/ui/CalendarHost.kt | 23 ++++- .../calendula/ui/common/HourGrid.kt | 13 ++- .../calendula/ui/common/TimelineScroll.kt | 54 +++++++++++ .../calendula/ui/common/ViewMorph.kt | 94 +++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 46 +++++---- .../calendula/ui/month/MonthScreen.kt | 2 + .../calendula/ui/week/WeekScreen.kt | 63 +++++++++---- 7 files changed, 254 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt 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 267ab39..c0b4b43 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -19,6 +20,9 @@ import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.domain.EventForm @@ -26,6 +30,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen +import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.identity.rememberReduceMotion @@ -34,8 +39,10 @@ import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars +import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel +import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform @@ -371,13 +378,23 @@ fun CalendarHost( val reduceMotion = rememberReduceMotion() val morphBounds = rememberViewMorphBoundsTransform() val morphFade = rememberCalendarFadeSpec() + val morphClip = remember { BelowTopBarClip() } + val density = LocalDensity.current + val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) + + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() } + val timelineScroll = remember { TimelineScrollMemory() } CompositionLocalProvider( LocalEventMove provides moveScope, + LocalTimelineScroll provides timelineScroll, // The failure state's way out when every calendar is switched off // (#239); nothing else in the calendar surfaces navigates here. LocalManageCalendars provides { showCalendars = true }, ) { - SharedTransitionLayout(Modifier.fillMaxSize()) { + SharedTransitionLayout( + Modifier + .fillMaxSize() + .onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y }, + ) { AnimatedContent( targetState = view, transitionSpec = { viewSwitch }, @@ -385,8 +402,8 @@ fun CalendarHost( ) { currentView -> val sharedScope = this@SharedTransitionLayout val visibilityScope = this@AnimatedContent - val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade) { - if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade) + val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip) { + if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip) } CompositionLocalProvider(LocalViewMorph provides viewMorph) { when (currentView) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt index 330fb43..82e4e96 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt @@ -57,9 +57,15 @@ internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Fl * drawn across one. Applied to a day column's content, so the cells sit over the * column background but beneath the event blocks — blocks stay in a continuous * coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel - * height; [color] is resolved by the caller from the theme. + * height; [color] is resolved by the caller from the theme. [offsetPx] shifts + * the cells up, for a fixed window over a scrolled column. */ -fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier = +fun Modifier.hourGridCells( + show: Boolean, + hourHeightPx: Float, + color: Color, + offsetPx: () -> Float = { 0f }, +): Modifier = if (!show) { this } else { @@ -71,10 +77,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()), ) val cellSize = Size(size.width, cellHeight) + val top = inset - offsetPx() for (hour in 0 until 24) { drawRoundRect( color = color, - topLeft = Offset(0f, hour * hourHeightPx + inset), + topLeft = Offset(0f, hour * hourHeightPx + top), size = cellSize, cornerRadius = radius, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt new file mode 100644 index 0000000..3694757 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt @@ -0,0 +1,54 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.flow.first +import kotlin.math.roundToInt + +/** + * Where 00:00 sat in the last timeline shown, measured from the top of its + * view's content, so the next timeline view opens on the same hours (#184): an + * event in both keeps its place and only grows sideways. + */ +class TimelineScrollMemory { + var midnightY: Float? = null +} + +val LocalTimelineScroll = staticCompositionLocalOf { null } + +/** + * The scroll state of a timeline whose scrolling area starts [timelineTop] below + * its view's content top. Opens where the last timeline left off, or on noon the + * first time. + */ +@Composable +fun rememberTimelineScrollState(timelineTop: Dp): ScrollState { + val memory = LocalTimelineScroll.current + val topPx = with(LocalDensity.current) { timelineTop.toPx() } + val remembered = memory?.midnightY + val scrollState = rememberScrollState( + initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0, + ) + val currentTop by rememberUpdatedState(topPx) + LaunchedEffect(scrollState) { + if (remembered != null) return@LaunchedEffect + snapshotFlow { scrollState.maxValue }.first { it > 0 } + // Half the scroll range *is* noon: the content spans a full 24 hours, so + // centring the range centres midday at whatever hour height is in force. + scrollState.scrollTo(scrollState.maxValue / 2) + } + if (memory != null) { + LaunchedEffect(scrollState, memory) { + snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it } + } + } + return scrollState +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt index 503cc32..ebad7c3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -7,6 +7,12 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -14,7 +20,13 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.datetime.LocalDate @@ -27,12 +39,17 @@ import kotlinx.datetime.LocalDate * multi-day event is drawn once per day, per week row or per timeline column, * and only one element per key may take part. The segment that starts on the * same day in both views is the one that travels; the rest fade where they are. + * + * A day's ground — its month cell, its timeline column — travels too, so the + * events never arrive ahead of what they sit on. */ sealed interface ViewMorphKey { data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey data class Day(val date: LocalDate) : ViewMorphKey + data class Ground(val date: LocalDate) : ViewMorphKey + companion object { /** [event]'s occurrence as drawn from [day]. */ fun event(event: EventInstance, day: LocalDate): Event = @@ -47,8 +64,30 @@ class ViewMorphScope( val visibility: AnimatedVisibilityScope, val boundsTransform: BoundsTransform, val fadeSpec: FiniteAnimationSpec, + val clip: SharedTransitionScope.OverlayClip, ) +/** + * Keeps what travels below the top bar, which every view has at the same height: + * the overlay draws above the whole screen, so an event scrolled under the bar + * would otherwise cross it. [top] is the bar's bottom edge in the shared layout. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +class BelowTopBarClip : SharedTransitionScope.OverlayClip { + var top = 0f + + override fun getClipPath( + sharedContentState: SharedTransitionScope.SharedContentState, + bounds: Rect, + layoutDirection: LayoutDirection, + density: Density, + ): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) } + + private companion object { + const val FAR = 1e6f + } +} + val LocalViewMorph = compositionLocalOf { null } /** @@ -84,6 +123,7 @@ fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier { enter = fadeIn(morph.fadeSpec), exit = fadeOut(morph.fadeSpec), boundsTransform = morph.boundsTransform, + clipInOverlayDuringTransition = morph.clip, resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, zIndexInOverlay = 1f, ) @@ -102,10 +142,64 @@ fun Modifier.morphDay(date: LocalDate): Modifier { enter = fadeIn(morph.fadeSpec), exit = fadeOut(morph.fadeSpec), boundsTransform = morph.boundsTransform, + clipInOverlayDuringTransition = morph.clip, + // Over the grounds, which a month cell's number sits inside. + zIndexInOverlay = 1f, ) } } +/** Tags a day's ground, under the events and numbers that travel with it. */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun Modifier.morphGround(date: LocalDate): Modifier { + val morph = LocalViewMorph.current ?: return this + return with(morph.shared) { + this@morphGround.sharedBounds( + sharedContentState = rememberSharedContentState(ViewMorphKey.Ground(date)), + animatedVisibilityScope = morph.visibility, + enter = fadeIn(morph.fadeSpec), + exit = fadeOut(morph.fadeSpec), + boundsTransform = morph.boundsTransform, + clipInOverlayDuringTransition = morph.clip, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + ) + } +} + +/** + * The on-screen window of each day's timeline column, painted like the column + * behind the scrolling one: a whole 24-hour column can't travel, its window can. + * Nothing is drawn while there is no morph to take part in. + */ +@Composable +fun TimelineGrounds( + days: List, + scrollState: ScrollState, + hourHeight: Dp, + columnGap: Dp, + modifier: Modifier = Modifier, +) { + if (LocalViewMorph.current == null) return + val showHourGrid = LocalShowHourGrid.current + val hourPx = with(LocalDensity.current) { hourHeight.toPx() } + val cell = MaterialTheme.colorScheme.surfaceContainer + val ground = if (showHourGrid) MaterialTheme.colorScheme.surface else cell + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(columnGap)) { + days.forEach { day -> + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .morphGround(day) + .clipToBounds() + .background(ground) + .hourGridCells(showHourGrid, hourPx, cell) { scrollState.value.toFloat() }, + ) + } + } +} + /** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */ @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable 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 6c817dc..89b0595 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,10 +12,12 @@ 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.TimelineGrounds import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage import de.jeanlucmakiola.calendula.ui.common.morphEvent import de.jeanlucmakiola.calendula.ui.common.morphPageActive +import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState import de.jeanlucmakiola.calendula.ui.common.pagedHeight import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute @@ -146,7 +148,7 @@ 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.week.TimedBlock -import kotlinx.coroutines.flow.first +import de.jeanlucmakiola.calendula.ui.week.weekDayHeaderHeight import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -307,15 +309,10 @@ private fun DayContent( val fadeSpec = rememberCalendarFadeSpec() // 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() - LaunchedEffect(Unit) { - snapshotFlow { scrollState.maxValue }.first { it > 0 } - // Half the scroll range *is* noon: the content spans a full 24 hours, so - // centring the range centres midday at whatever hour height is in force. - scrollState.scrollTo(scrollState.maxValue / 2) - } + // survives day-to-day swipes, and carried over from the timeline view + // before this one. + val stripHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp + val scrollState = rememberTimelineScrollState(stripHeight + TIMELINE_TOP_GAP) // Above the pager: a page change mid-drag would strand the floating block // inside the outgoing page. @@ -444,14 +441,15 @@ private fun DayFrame( // timelines have, so labels and column agree. BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val zoom = LocalTimelineZoom.current - val hourHeight = zoom.scale.hourHeight(maxHeight) + val fitHeight = maxHeight + allDayHeight - weekDayHeaderHeight() + val hourHeight = zoom.scale.hourHeight(fitHeight) // 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), + modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom), ) } } @@ -521,6 +519,7 @@ private fun DayPage( Spacer(Modifier.height(TIMELINE_TOP_GAP)) Timeline( state = state, + allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, active = active, @@ -672,6 +671,7 @@ private fun AllDayBar( @Composable private fun Timeline( state: DayUiState.Success, + allDayHeight: Dp, scrollState: ScrollState, dragController: TimelineDragController, active: Boolean, @@ -692,14 +692,27 @@ private fun Timeline( // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top - // bar, date header and all-day strip. + // bar and all-day strip. It fits the week view's timeline instead, so an + // hour is as tall here as there (#184). + val weekHeader = weekDayHeaderHeight() BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val hourHeight = zoom.scale.hourHeight(maxHeight) + val fitHeight = maxHeight + allDayHeight - weekHeader + val hourHeight = zoom.scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // 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) + val pinch = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom) + TimelineGrounds( + days = listOf(state.date), + scrollState = scrollState, + hourHeight = hourHeight, + columnGap = 0.dp, + modifier = Modifier + .fillMaxSize() + .padding(end = TIMELINE_CONTENT_END_INSET) + .clip(RoundedCornerShape(16.dp)), + ) // 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 @@ -975,10 +988,11 @@ private fun EventBlock( private fun DayLoading() { val scale = LocalTimelineZoom.current.scale val scrollState = rememberScrollState() + val weekHeader = weekDayHeaderHeight() BoxWithConstraints(modifier = Modifier.fillMaxSize()) { // Same scale resolution as the loaded timeline, so the skeleton's column // doesn't resize the moment the real day arrives. - val hourHeight = scale.hourHeight(maxHeight) + val hourHeight = scale.hourHeight(maxHeight - weekHeader - TIMELINE_TOP_GAP) val totalHeight = hourHeight * 24 // The skeleton wears the loaded column's own ground, so the arrival of // the real day doesn't flash a solid block into a gapped grid. 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 cfc7fc8..c31a96f 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 @@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.morphGround import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.pagedHeight @@ -2185,6 +2186,7 @@ private fun MonthWeekRow( .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) .morphElement(MonthMorphKey.Cell(d)) + .morphGround(d) .background( color = when { inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer 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 b9e9b80..13f9450 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 @@ -135,10 +135,12 @@ 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.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.TimelineGrounds import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.floret.identity.rememberReduceMotion @@ -159,7 +161,6 @@ import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -183,6 +184,15 @@ 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 +/** The weekday header's height, which the day view takes off to fit the week's hours (#184). */ +@Composable +internal fun weekDayHeaderHeight(): Dp { + val label = MaterialTheme.typography.labelSmall + val line = label.lineHeight.takeIf { it.isSp } ?: label.fontSize + return HEADER_TOP_PADDING + with(LocalDensity.current) { line.toDp() } + 2.dp + + DATE_SLOT_SIZE + HEADER_BOTTOM_PADDING +} + /** 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 @@ -419,16 +429,10 @@ private fun WeekContent( val fadeSpec = rememberCalendarFadeSpec() // 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. - val scrollState = rememberScrollState() - LaunchedEffect(Unit) { - snapshotFlow { scrollState.maxValue }.first { it > 0 } - // Half the scroll range *is* noon: the content spans a full 24 hours, so - // centring the range centres midday at whatever hour height is in force. - scrollState.scrollTo(scrollState.maxValue / 2) - } + // survives week-to-week swipes (e.g. 18:00 stays centred), and carried over + // from the timeline view before this one. + val stripHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp + val scrollState = rememberTimelineScrollState(weekDayHeaderHeight() + stripHeight + TIMELINE_TOP_GAP) // Above the pager: a page change mid-drag would strand the floating block // inside the outgoing page. @@ -587,12 +591,13 @@ private fun WeekFrame( // timelines have, so labels and columns agree. BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val zoom = LocalTimelineZoom.current - val hourHeight = zoom.scale.hourHeight(maxHeight) + val fitHeight = maxHeight + allDayHeight + val hourHeight = zoom.scale.hourHeight(fitHeight) HourGutter( scrollState = scrollState, hourHeight = hourHeight, dragController = dragController, - modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom), + modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom), ) } } @@ -670,6 +675,7 @@ private fun WeekPage( Spacer(Modifier.height(TIMELINE_TOP_GAP)) Timeline( state = state, + allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, active = active, @@ -940,6 +946,7 @@ private fun AllDayBar( @Composable private fun Timeline( state: WeekUiState.Success, + allDayHeight: Dp, scrollState: ScrollState, dragController: TimelineDragController, active: Boolean, @@ -960,14 +967,26 @@ private fun Timeline( // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top - // bar, day header and all-day strip. + // bar, day header and all-day strip. The strip is given back, so the scale + // holds while paging and matches the day view's (#184). BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val hourHeight = zoom.scale.hourHeight(maxHeight) + val fitHeight = maxHeight + allDayHeight + val hourHeight = zoom.scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // 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) + val pinch = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom) + TimelineGrounds( + days = state.days, + scrollState = scrollState, + hourHeight = hourHeight, + columnGap = COLUMN_GAP, + modifier = Modifier + .fillMaxSize() + .padding(end = TIMELINE_CONTENT_END_INSET) + .clip(RoundedCornerShape(16.dp)), + ) // 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 @@ -1267,20 +1286,24 @@ private fun EventBlock( } } +private val SKELETON_HEADER_HEIGHT = 36.dp +private val SKELETON_HEADER_PADDING = 8.dp + @Composable private fun WeekLoading(dayCount: Int) { val scale = LocalTimelineZoom.current.scale val scrollState = rememberScrollState() + val headerHeight = weekDayHeaderHeight() Column(modifier = Modifier.fillMaxSize()) { // Header skeleton - Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Row(modifier = Modifier.fillMaxWidth().padding(SKELETON_HEADER_PADDING)) { Spacer(Modifier.width(GUTTER_WIDTH)) repeat(dayCount) { Box( modifier = Modifier .weight(1f) .padding(horizontal = 2.dp) - .height(36.dp) + .height(SKELETON_HEADER_HEIGHT) .background( MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(8.dp), @@ -1291,7 +1314,9 @@ private fun WeekLoading(dayCount: Int) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { // Same scale resolution as the loaded timeline, so the skeleton's // columns don't resize the moment the real week arrives. - val hourHeight = scale.hourHeight(maxHeight) + val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 - + headerHeight - TIMELINE_TOP_GAP + val hourHeight = scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The skeleton wears the loaded columns' own ground, so the arrival // of the real week doesn't flash solid blocks into a gapped grid. From f9738b0c23897ae123c322b8f42a0379a38f78d4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:13:47 +0200 Subject: [PATCH 10/17] Put the day view on the paged timeline (#184) --- .../calendula/ui/CalendarHost.kt | 2 +- .../calendula/ui/day/DayPaging.kt | 22 - .../calendula/ui/day/DayScreen.kt | 1038 ----------------- .../calendula/ui/day/DayUiState.kt | 25 - .../calendula/ui/day/DayViewModel.kt | 136 --- .../calendula/ui/day/DayViewPreview.kt | 63 - .../calendula/ui/onboarding/ViewStep.kt | 3 +- .../calendula/ui/week/WeekPaging.kt | 20 + .../calendula/ui/week/WeekScreen.kt | 114 +- .../calendula/ui/week/WeekViewModel.kt | 15 + .../calendula/ui/week/WeekViewPreview.kt | 1 + .../calendula/ui/day/DayPagingTest.kt | 22 - .../calendula/ui/week/WeekPagingTest.kt | 14 + 13 files changed, 150 insertions(+), 1325 deletions(-) delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt delete mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt 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 c0b4b43..345c75a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -50,7 +50,7 @@ import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.selectView import de.jeanlucmakiola.calendula.ui.common.viewBaseStack -import de.jeanlucmakiola.calendula.ui.day.DayScreen +import de.jeanlucmakiola.calendula.ui.week.DayScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.edit.ImportSource 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 deleted file mode 100644 index b932d7d..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayPaging.kt +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 89b0595..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ /dev/null @@ -1,1038 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import androidx.compose.animation.AnimatedContent -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.TimelineGrounds -import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey -import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage -import de.jeanlucmakiola.calendula.ui.common.morphEvent -import de.jeanlucmakiola.calendula.ui.common.morphPageActive -import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState -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 -import kotlinx.coroutines.flow.drop -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.customActions -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.ui.common.LocalChipGround -import de.jeanlucmakiola.calendula.ui.common.eventPaint -import de.jeanlucmakiola.calendula.ui.common.eventSurface -import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer -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.CalendarView -import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET -import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING -import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel -import de.jeanlucmakiola.calendula.ui.common.BlockTitle -import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics -import de.jeanlucmakiola.calendula.ui.common.blockTimeLines -import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement -import de.jeanlucmakiola.calendula.ui.common.ghostAlpha -import de.jeanlucmakiola.calendula.ui.common.LocalEventMove -import de.jeanlucmakiola.calendula.ui.common.MoveRequest -import de.jeanlucmakiola.calendula.ui.common.MoveTarget -import de.jeanlucmakiola.calendula.ui.common.NowLine -import de.jeanlucmakiola.calendula.ui.common.TimelineDragController -import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay -import de.jeanlucmakiola.calendula.ui.common.TimelineDrop -import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes -import de.jeanlucmakiola.calendula.ui.common.continuesAfter -import de.jeanlucmakiola.calendula.ui.common.continuesBefore -import de.jeanlucmakiola.calendula.ui.common.ChipCuts -import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts -import de.jeanlucmakiola.calendula.ui.common.timedBlockShape -import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed -import de.jeanlucmakiola.calendula.ui.common.eventMoveAction -import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow -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.rememberCalendarFadeSpec -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.floret.locale.currentLocale -import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat -import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid -import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom -import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION -import de.jeanlucmakiola.calendula.ui.common.hourHeight -import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom -import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay -import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH -import de.jeanlucmakiola.calendula.ui.common.HourGutter -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.week.TimedBlock -import de.jeanlucmakiola.calendula.ui.week.weekDayHeaderHeight -import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime -import java.util.Locale -import kotlin.math.roundToInt - -/** One lane of the all-day strip, sized to a trimmed bar line (#190). */ -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 - val lanes = allDay.maxOf { it.lane } + 1 - return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DayScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> 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, - initialDateIso: String? = null, - viewModel: DayViewModel = hiltViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle() - - // When opened from the month grid, anchor to the tapped date. - LaunchedEffect(initialDateIso) { - initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) } - } - - val drawerState = rememberDrawerState(DrawerValue.Closed) - val scope = rememberCoroutineScope() - - // 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) - } - } - 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 -> - scope.launch { pagerState.jumpToPage(dayPageFor(target), reduceMotion, pageSpec) } - } - val jumpToToday = { jumpToDate(today) } - - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the day swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = date, - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - jumpToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, - ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - DayTopBar( - date = date, - currentYear = today.year, - selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - onJumpToDate = jumpToDate, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) - }, - floatingActionButton = { - CalendarFabColumn( - todayVisible = !isOnToday && !todayInToolbar, - todayText = stringResource(R.string.day_today_action), - onToday = jumpToToday, - onCreate = { onCreateEvent(date, null) }, - ) - }, - ) { innerPadding -> - DayContent( - state = state, - pagerState = pagerState, - day = viewModel::day, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) - } - } -} - -@Composable -private fun DayContent( - state: DayUiState, - pagerState: PagerState, - day: (LocalDate) -> StateFlow, - onRetry: () -> Unit, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - modifier: Modifier = Modifier, -) { - val fadeSpec = rememberCalendarFadeSpec() - - // Shared by every page and the gutter, so the vertical scroll position - // survives day-to-day swipes, and carried over from the timeline view - // before this one. - val stripHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp - val scrollState = rememberTimelineScrollState(stripHeight + TIMELINE_TOP_GAP) - - // 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(), - 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 -> DayPager( - pagerState = pagerState, - day = day, - today = s.today, - initialStripHeight = s.allDayStripHeight(), - scrollState = scrollState, - dragController = dragController, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) - } - } - TimelineDragOverlay(dragController) - } -} - -/** - * 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 = pagedHeight(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) } - } - ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { - 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 fitHeight = maxHeight + allDayHeight - weekDayHeaderHeight() - val hourHeight = zoom.scale.hourHeight(fitHeight) - // 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, fitHeight, 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, - scrollState: ScrollState, - allDayHeight: Dp, - dragController: TimelineDragController, - 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, - // so the timeline sits directly under the app bar. - // This strip is painted on `surface`, so an outlined chip has to fill - // with that and not with the column's container (#230). - CompositionLocalProvider( - LocalChipGround provides MaterialTheme.colorScheme.surface, - ) { - AllDayStrip( - state = state, - height = allDayHeight, - onEventClick = onEventClick, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface), - ) - } - Spacer(Modifier.height(TIMELINE_TOP_GAP)) - Timeline( - state = state, - allDayHeight = allDayHeight, - scrollState = scrollState, - dragController = dragController, - active = active, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DayTopBar( - date: LocalDate, - currentYear: Int, - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - onJumpToDate: (LocalDate) -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - val locale = currentLocale() - val (title, shortTitle) = remember(date, locale, currentYear) { - formatDayTitle(date, locale, currentYear) to - formatDayTitle(date, locale, currentYear, abbreviated = true) - } - TopAppBar( - title = { - CalendarTitleButton( - title = title, - currentDate = date, - onJumpToDate = onJumpToDate, - shortTitle = shortTitle, - ) - }, - navigationIcon = { - IconButton(onClick = onOpenDrawer) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(R.string.month_open_menu), - ) - } - }, - actions = { - TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search_action), - ) - } - ViewSwitcherPill( - current = selectedView, - cycle = quickSwitchViews, - onCycle = onCycleView, - ) - }, - // Deliberately flat: M3 lifts the bar to mark content scrolling under - // it, but here the bar meets the header on the same surface and the - // tint is what makes that seam look like a separate block (#186). - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) -} - -@Composable -private fun AllDayStrip( - state: DayUiState.Success, - height: Dp, - onEventClick: (EventInstance) -> Unit, - modifier: Modifier = Modifier, -) { - val dark = isSystemInDarkTheme() - - Row( - modifier = modifier - // Height is hoisted + animated so it resizes smoothly; padding sits - // inside it so the content area is lanes * row height. - .height(height) - .padding( - top = ALL_DAY_VERTICAL_PADDING, - bottom = ALL_DAY_VERTICAL_PADDING, - end = TIMELINE_CONTENT_END_INSET, - ), - ) { - // 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. - BoxWithConstraints( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clipToBounds(), - ) { - val barWidth = maxWidth - state.allDay.forEach { span -> - AllDayBar( - event = span.event, - dark = dark, - onClick = { onEventClick(span.event) }, - modifier = Modifier - .offset(y = ALL_DAY_ROW_HEIGHT * span.lane) - .morphEvent(ViewMorphKey.event(span.event, state.date)) - .width(barWidth) - .height(ALL_DAY_ROW_HEIGHT) - .padding(horizontal = 1.dp, vertical = 1.dp), - ) - } - } - } -} - -@Composable -private fun AllDayBar( - event: EventInstance, - dark: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val title = event.title.ifBlank { stringResource(R.string.event_untitled) } - val paint = eventPaint(event, dark) - Box( - modifier = modifier - .eventSurface(paint, EventChipShape) - .clickable(onClick = onClick) - .padding(horizontal = 6.dp, vertical = 2.dp) - .semantics { contentDescription = title }, - contentAlignment = Alignment.CenterStart, - ) { - val titleOverflow = eventTitleOverflow() - Text( - text = title, - style = MaterialTheme.typography.labelSmall.trimmedLines(), - maxLines = 1, - overflow = titleOverflow.overflow, - softWrap = titleOverflow.softWrap, - color = paint.titleInk, - fontWeight = paint.titleWeight, - textDecoration = paint.decoration, - ) - } -} - -@Composable -private fun Timeline( - state: DayUiState.Success, - allDayHeight: Dp, - scrollState: ScrollState, - dragController: TimelineDragController, - active: Boolean, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, -) { - val dark = isSystemInDarkTheme() - val zoom = LocalTimelineZoom.current - val density = LocalDensity.current - val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - // What the hour grid takes off a block's ends, for the floating copy to - // take off its own. Per-end, a cut edge keeps it — that much the drag - // geometry decides itself, from the slice it is drawing. - val blockInsetPx = with(density) { - hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx() - } - - // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the - // timeline's own viewport height, which is only known here — below the top - // bar and all-day strip. It fits the week view's timeline instead, so an - // hour is as tall here as there (#184). - val weekHeader = weekDayHeaderHeight() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val fitHeight = maxHeight + allDayHeight - weekHeader - val hourHeight = zoom.scale.hourHeight(fitHeight) - val totalHeight = hourHeight * 24 - // 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, fitHeight, hourHeight, zoom) - TimelineGrounds( - days = listOf(state.date), - scrollState = scrollState, - hourHeight = hourHeight, - columnGap = 0.dp, - modifier = Modifier - .fillMaxSize() - .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)), - ) - // 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, - 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. 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 - } - }, - ) - } - } -} - -@Composable -private fun DayColumnCard( - blocks: List, - dark: Boolean, - date: LocalDate, - today: LocalDate, - hourHeight: Dp, - dragController: TimelineDragController, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, - modifier: Modifier = Modifier, -) { - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } - val showHourGrid = LocalShowHourGrid.current - val zone = remember { TimeZone.currentSystemDefault() } - // Tells a settled drop when this column has caught up with it. - LaunchedEffect(blocks, dragController.settling) { - dragController.noteGrid(date, blocks) - } - // The tap detector outlives the composition that installed it — a pinch or a - // Settings change moves the hour height without restarting it — so it reads - // the height and the callback through state handles instead of capturing - // them, or taps land on the scale the column had before the zoom (#148). - val currentHourPx = rememberUpdatedState(hourPx) - val currentOnCreateAt = rememberUpdatedState(onCreateAt) - Card( - // Plain rectangular column — the soft corners come from the outer - // rounded scroll viewport, so inner rounding would look odd at the edges. - shape = RectangleShape, - colors = CardDefaults.cardColors( - // With the grid on, the container colour moves onto the hour cells - // and the column behind them recedes to surface, so an hour boundary - // reads as negative space between two surfaces. - containerColor = if (showHourGrid) { - MaterialTheme.colorScheme.surface - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - ), - modifier = modifier, - ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - // The hour cells sit over the column background but under the - // event blocks (drawBehind paints before the children). - .hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer) - // Tap an empty slot to create an event there. Taps on event - // blocks are consumed by their own click handler first, so this - // only fires on the column background. Snaps to the tapped hour. - .pointerInput(date) { - detectTapGestures { offset -> - currentOnCreateAt.value( - date, - tappedMinuteOfDay(offset.y, currentHourPx.value), - ) - } - }, - ) { - val colWidth = maxWidth - val minEventHeight = hourHeight * MIN_EVENT_FRACTION - // Keyed by event, so a block that changes time or lane is the *same* - // composable afterwards and tweens there. The ordinal disambiguates - // a column holding two occurrences of one series. - val ordinals = mutableMapOf() - blocks.forEach { block -> - val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 - key(block.event.eventId, ordinal) { - val laneWidth = colWidth / block.laneCount - val top = hourHeight * (block.startMin / 60f) - val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight - val place = animatedBlockPlacement( - x = laneWidth * block.lane, - y = top, - width = laneWidth, - height = height, - ) - // Where this block is cut at midnight, which decides both - // its shape and the half-gap it gives up to the hour cells. - val cuts = remember(block, date, zone) { - timedBlockCuts( - block.continuesBefore(date, zone), - block.continuesAfter(date, zone), - ) - } - val topInset = hourCellBlockInset(showHourGrid, cuts.top) - val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) - val blockHeight = (place.height - topInset - bottomInset) - .coerceAtLeast(0.dp) - EventBlock( - block = block, - dark = dark, - height = blockHeight, - width = place.width, - date = date, - cuts = cuts, - topInset = topInset, - dragController = dragController, - onClick = { onEventClick(block.event) }, - onDrop = onDrop, - modifier = Modifier - .offset(x = place.x, y = place.y + topInset) - .morphEvent(ViewMorphKey.event(block.event, date)) - .width(place.width) - .height(blockHeight) - .padding(horizontal = BLOCK_OUTER_INSET), - ) - } - } - // Current-time line, on top of the events, only on today's column. - if (date == today) { - NowLine(date = date, hourHeight = hourHeight) - } - } - } -} - -@Composable -private fun EventBlock( - block: TimedBlock, - dark: Boolean, - height: Dp, - width: Dp, - date: LocalDate, - cuts: ChipCuts, - topInset: Dp, - dragController: TimelineDragController, - onClick: () -> Unit, - onDrop: (TimelineDrop) -> Unit, - modifier: Modifier = Modifier, -) { - val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } - val use24Hour = LocalUse24HourFormat.current - val locale = currentLocale() - val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + - minToHm(block.endMin, use24Hour, locale) - val density = LocalDensity.current - val metrics = rememberBlockTextMetrics(height) - // A block that cannot afford both lines spends its space on the title, and - // one too short even for that drops the title rather than serving a sliced one. - // Height alone decides: a duration threshold would keep hiding the time on a - // half-hour block the user has pinched open to three times the room it needs. - val showTime = metrics.available >= metrics.titleLine + metrics.timeLine - val showTitle = metrics.fitsTitle - val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 - // Only lines the block can actually draw: a block too short for the time is - // too short for a second title line too, and asking for one served a sliced - // one — as well as handing the drag copy a count it couldn't honour, so the - // title re-wrapped the moment the block was lifted (#267). - val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1) - val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2) - // On a day column — wide enough for "09:30–11:00" several times over — the - // range never needs the second line, until lanes cut the column down. - val spare = metrics.available - metrics.titleHeight(titleMaxLines) - - if (showTime) metrics.timeLine else 0.dp - val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1 - val paint = eventPaint(block.event, dark) - val zone = remember { TimeZone.currentSystemDefault() } - val moveAction = eventMoveAction(block.event) - val draggable = eventDragAllowed(block.event) - // The drop takes this offset back off, so a tail clipped at midnight lands - // where the event's own start belongs (#253). - val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } - val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) } - val topInsetPx = with(density) { topInset.toPx() } - val dragModifier = rememberEventDragSource( - enabled = draggable, - key = block.event.instanceId, - onPickUp = { pointer, blockRoot, _ -> - dragController.begin( - block = block, - clipOffsetMin = clipOffset, - titleLines = if (showTitle) titleMaxLines else 0, - pointerInRoot = pointer, - // Measured off the block's placement rather than off where the - // hour grid seats it: the copy is drawn from that placement and - // re-seated the same half-gap down (#267). - blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx), - ) - }, - onMove = dragController::move, - onDrop = { dragController.finish()?.let(onDrop) }, - onCancel = dragController::cancel, - ) - val lifted = draggable && dragController.ghosts(block) - val ghost = ghostAlpha(lifted) - Box( - modifier = modifier - // The source stays put as a ghost while its floating copy travels. - .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) - .eventSurface(paint, shape, cuts) - .clickable(onClick = onClick) - // After clickable, so it is the inner node and wins the main pass; - // the tap still works, since a drag consumes the up. - .then(dragModifier) - .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset) - .semantics { - contentDescription = "$title, $timeLabel" - if (moveAction != null) customActions = listOf(moveAction) - }, - ) { - Column { - if (showTitle) { - BlockTitle( - title = title, - maxLines = titleMaxLines, - color = paint.titleInk, - textDecoration = paint.decoration, - fontWeight = paint.titleWeight, - ) - } - if (showTime) { - BlockTimeLabel( - label = timeLabel, - color = paint.secondaryInk, - maxLines = timeMaxLines, - ) - } - } - } -} - -@Composable -private fun DayLoading() { - val scale = LocalTimelineZoom.current.scale - val scrollState = rememberScrollState() - val weekHeader = weekDayHeaderHeight() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - // Same scale resolution as the loaded timeline, so the skeleton's column - // doesn't resize the moment the real day arrives. - val hourHeight = scale.hourHeight(maxHeight - weekHeader - TIMELINE_TOP_GAP) - val totalHeight = hourHeight * 24 - // The skeleton wears the loaded column's own ground, so the arrival of - // the real day doesn't flash a solid block into a gapped grid. - val showHourGrid = LocalShowHourGrid.current - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } - Row( - modifier = Modifier - .fillMaxSize() - .padding(end = TIMELINE_CONTENT_END_INSET) - .verticalScroll(scrollState), - ) { - Spacer(Modifier.width(GUTTER_WIDTH)) - Box( - modifier = Modifier - .weight(1f) - .height(totalHeight) - .padding(horizontal = 2.dp) - .background( - if (showHourGrid) MaterialTheme.colorScheme.surface - else MaterialTheme.colorScheme.surfaceContainer, - ) - .hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer), - ) - } - } -} - -private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String = - formatMinuteOfDay(min, is24Hour, locale) - -/** [abbreviated] drops the weekday, leaving the date itself (#165). */ -private fun formatDayTitle( - date: LocalDate, - locale: Locale, - currentYear: Int, - abbreviated: Boolean = false, -): String = - formatCalendarTitle( - date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), - locale = locale, - currentYear = currentYear, - skeleton = if (abbreviated) "dMMM" else "EEEdMMM", - ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt deleted file mode 100644 index 2883366..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt +++ /dev/null @@ -1,25 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import de.jeanlucmakiola.calendula.domain.FailureReason -import de.jeanlucmakiola.calendula.ui.week.AllDaySpan -import de.jeanlucmakiola.calendula.ui.week.TimedBlock -import kotlinx.datetime.LocalDate - -/** - * The day view is a single-column slice of the week view (spec S3). It reuses the - * week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day - * spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol] - * == 0) and only their [AllDaySpan.lane] (vertical stacking) matters. - */ -sealed interface DayUiState { - data object Loading : DayUiState - data class Failure(val reason: FailureReason) : DayUiState - data class Success( - val date: LocalDate, - val today: LocalDate, - /** All-day/multi-day events covering this day, stacked by lane. */ - val allDay: List, - /** Timed events clipped to this day with overlap lanes resolved. */ - val timed: List, - ) : DayUiState -} 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 deleted file mode 100644 index e310d49..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt +++ /dev/null @@ -1,136 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository -import de.jeanlucmakiola.calendula.data.di.IoDispatcher -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 -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -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.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.atTime -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 -class DayViewModel @Inject constructor( - private val repository: CalendarRepository, - @IoDispatcher private val io: CoroutineDispatcher, -) : ViewModel() { - - private val zone = TimeZone.currentSystemDefault() - - private val todayDate: LocalDate - get() = Clock.System.now().toLocalDateTime(zone).date - - private val _date = MutableStateFlow(todayDate) - - /** 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 { date -> day(date).filterNot { it is DayUiState.Loading } } - .catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = DayUiState.Loading, - ) - - 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) - } - - /** 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() { - _date.value = todayDate - } - - /** Jump to a specific date (e.g. when opened from the month grid). */ - fun goToDate(date: LocalDate) { - _date.value = date - } - - private fun buildState( - day: LocalDate, - calendars: List, - instances: List, - ): DayUiState { - calendarListFailure(calendars)?.let { return DayUiState.Failure(it) } - val days = listOf(day) - val allDay = instances.filter { it.isAllDay } - val timed = instances.filterNot { it.isAllDay } - return DayUiState.Success( - date = day, - today = todayDate, - allDay = layoutAllDay(allDay, days, zone), - timed = layoutDay(timed, day, zone), - ) - } -} - -/** Half-open instant range covering the single calendar [date]. */ -internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange { - val from = date.atStartOfDayIn(zone) - val to = date.atTime(23, 59, 59).toInstant(zone) - return from..to -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt deleted file mode 100644 index 90303d9..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt +++ /dev/null @@ -1,63 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import androidx.compose.foundation.rememberScrollState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview -import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController -import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents -import de.jeanlucmakiola.calendula.ui.week.layoutAllDay -import de.jeanlucmakiola.calendula.ui.week.layoutDay -import kotlinx.coroutines.flow.first -import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime -import kotlin.time.Clock - -/** - * A live, scaled-down Day view for the onboarding view chooser. Today's date is - * real; only the events are stand-ins. - */ -@Composable -internal fun DayViewPreview( - height: Dp, - modifier: Modifier = Modifier, -) { - val zone = remember { TimeZone.currentSystemDefault() } - val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date } - val state = remember(today, zone) { sampleDayState(today, zone) } - val scrollState = rememberScrollState() - LaunchedEffect(Unit) { - snapshotFlow { scrollState.maxValue }.first { it > 0 } - // Half the scroll range is noon — the same centring the live view does. - scrollState.scrollTo(scrollState.maxValue / 2) - } - - ScaledViewPreview(height = height, modifier = modifier) { - DaySuccess( - state = state, - scrollState = scrollState, - allDayHeight = state.allDayStripHeight(), - dragController = rememberTimelineDragController(), - onEventClick = {}, - onCreateAt = { _, _ -> }, - onDrop = {}, - ) - } -} - -/** Sample day state, laid out through the same helpers the live view uses. */ -private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success { - val days = listOf(today) - val events = sampleTimelineEvents(days, today, zone) - return DayUiState.Success( - date = today, - today = today, - allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone), - timed = layoutDay(events, today, zone), - ) -} 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 8f71abc..defdd8b 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 @@ -23,7 +23,6 @@ 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.label -import de.jeanlucmakiola.calendula.ui.day.DayViewPreview import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.descriptionRes @@ -73,7 +72,7 @@ internal fun ViewStep( height = PREVIEW_HEIGHT, dayCount = LocalMultiDayCount.current, ) - CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT) + CalendarView.Day -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT, dayCount = 1) CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT) } } 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 5c2d92d..e9f43f6 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 @@ -73,3 +73,23 @@ data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid { DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount)) } } + +/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */ +internal const val DAY_PAGE_COUNT: Int = 140_000 + +private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2 + +/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */ +private val DAY_EPOCH = LocalDate(2000, 1, 1) + +/** Single days: every date starts a page of its own. */ +data object DayGrid : PageGrid { + override val dayCount: Int get() = 1 + override val pageCount: Int get() = DAY_PAGE_COUNT + + override fun startForPage(page: Int): LocalDate = + DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY) + + override fun pageFor(date: LocalDate): Int = + (DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_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 13f9450..4e67dd3 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 @@ -66,6 +66,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip @@ -184,6 +185,9 @@ 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 +/** A start-aligned header cell's inset, lining its date up with the event titles below. */ +private val HEADER_START_INSET = BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING + /** The weekday header's height, which the day view takes off to fit the week's hours (#184). */ @Composable internal fun weekDayHeaderHeight(): Dp { @@ -216,7 +220,7 @@ fun WeekScreen( viewModel: WeekViewModel = hiltViewModel(), ) = PagedTimelineScreen( viewModel = viewModel, - showWeekNumber = true, + kind = TimelineKind.Week, selectedView = selectedView, onSelectView = onSelectView, onOpenDay = onOpenDay, @@ -247,8 +251,7 @@ fun MultiDayScreen( viewModel: MultiDayViewModel = hiltViewModel(), ) = PagedTimelineScreen( viewModel = viewModel, - // A run of days often straddles two weeks, so no single number fits it. - showWeekNumber = false, + kind = TimelineKind.MultiDay, selectedView = selectedView, onSelectView = onSelectView, onOpenDay = onOpenDay, @@ -262,11 +265,66 @@ fun MultiDayScreen( modifier = modifier, ) +/** The day view: the week view's timeline one day at a time. */ +@Composable +fun DayScreen( + selectedView: CalendarView, + onSelectView: (CalendarView) -> 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, + initialDateIso: String? = null, + viewModel: DayViewModel = hiltViewModel(), +) = PagedTimelineScreen( + viewModel = viewModel, + kind = TimelineKind.Day, + selectedView = selectedView, + onSelectView = onSelectView, + // Tapping the one column's header would only reopen this day. + onOpenDay = {}, + onEventClick = onEventClick, + onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, + onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, + todayInToolbar = todayInToolbar, + modifier = modifier, + initialDate = initialDateIso?.let(LocalDate::parse), +) + +/** What sets the timeline views apart; everything else they share. */ +internal enum class TimelineKind( + val showWeekNumber: Boolean, + val todayText: Int, + /** Where a header cell sits in its column: centred, or at the start like the event titles. */ + val headerBias: Float, +) { + Week(showWeekNumber = true, todayText = R.string.week_today_action, headerBias = 0f), + + // A run of days often straddles two weeks, so no single number fits it. + MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f), + Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f), + ; + + fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String = + if (this == Day) { + formatDayTitle(start, locale, currentYear, abbreviated) + } else { + formatWeekTitle(start, end, locale, currentYear, abbreviated) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PagedTimelineScreen( viewModel: PagedTimelineViewModel, - showWeekNumber: Boolean, + kind: TimelineKind, selectedView: CalendarView, onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, @@ -278,7 +336,9 @@ private fun PagedTimelineScreen( drawerViewOrder: List, todayInToolbar: Boolean, modifier: Modifier, + initialDate: LocalDate? = null, ) { + LaunchedEffect(initialDate) { initialDate?.let(viewModel::jumpTo) } val state by viewModel.state.collectAsStateWithLifecycle() val position by viewModel.position.collectAsStateWithLifecycle() val grid = position.grid @@ -368,6 +428,7 @@ private fun PagedTimelineScreen( modifier = modifier, topBar = { WeekTopBar( + kind = kind, weekStart = weekStart, weekEnd = weekEnd, currentYear = today.year, @@ -384,7 +445,7 @@ private fun PagedTimelineScreen( floatingActionButton = { CalendarFabColumn( todayVisible = !isOnCurrentWeek && !todayInToolbar, - todayText = stringResource(R.string.week_today_action), + todayText = stringResource(kind.todayText), onToday = jumpToToday, onCreate = { // Anchor on today when it's in view, else the week's first day. @@ -398,7 +459,7 @@ private fun PagedTimelineScreen( state = state, pagerState = pagerState, grid = grid, - showWeekNumber = showWeekNumber, + kind = kind, span = viewModel::span, onRetry = viewModel::goToToday, onEventClick = onEventClick, @@ -418,7 +479,7 @@ private fun WeekContent( state: WeekUiState, pagerState: PagerState, grid: PageGrid, - showWeekNumber: Boolean, + kind: TimelineKind, span: (DaySpan) -> StateFlow, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, @@ -466,7 +527,7 @@ private fun WeekContent( is WeekUiState.Success -> WeekPager( pagerState = pagerState, grid = grid, - showWeekNumber = showWeekNumber, + kind = kind, span = span, today = s.today, initialStripHeight = s.allDayStripHeight(), @@ -491,7 +552,7 @@ private fun WeekContent( private fun WeekPager( pagerState: PagerState, grid: PageGrid, - showWeekNumber: Boolean, + kind: TimelineKind, span: (DaySpan) -> StateFlow, today: LocalDate, initialStripHeight: Dp, @@ -506,7 +567,7 @@ private fun WeekPager( val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight) WeekFrame( weekStart = grid.startForPage(pagerState.currentPage), - showWeekNumber = showWeekNumber, + showWeekNumber = kind.showWeekNumber, allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, @@ -537,6 +598,7 @@ private fun WeekPager( ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { WeekPage( state = loaded ?: empty, + headerBias = kind.headerBias, allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, @@ -610,6 +672,7 @@ private fun WeekFrame( internal fun WeekSuccess( state: WeekUiState.Success, showWeekNumber: Boolean = true, + headerBias: Float = 0f, scrollState: ScrollState, allDayHeight: Dp, dragController: TimelineDragController, @@ -627,6 +690,7 @@ internal fun WeekSuccess( ) { WeekPage( state = state, + headerBias = headerBias, allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, @@ -648,6 +712,7 @@ internal fun WeekSuccess( @Composable private fun WeekPage( state: WeekUiState.Success, + headerBias: Float, allDayHeight: Dp, scrollState: ScrollState, dragController: TimelineDragController, @@ -663,7 +728,7 @@ private fun WeekPage( .fillMaxWidth() .background(MaterialTheme.colorScheme.surface), ) { - WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay) + WeekDayHeader(days = state.days, today = state.today, bias = headerBias, onOpenDay = onOpenDay) // This strip is painted on `surface`, so an outlined chip has to // fill with that and not with the columns' container (#230). CompositionLocalProvider( @@ -689,6 +754,7 @@ private fun WeekPage( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun WeekTopBar( + kind: TimelineKind, weekStart: LocalDate, weekEnd: LocalDate, currentYear: Int, @@ -702,9 +768,9 @@ private fun WeekTopBar( onToday: () -> Unit, ) { val locale = currentLocale() - val (title, shortTitle) = remember(weekStart, weekEnd, locale, currentYear) { - formatWeekTitle(weekStart, weekEnd, locale, currentYear) to - formatWeekTitle(weekStart, weekEnd, locale, currentYear, abbreviated = true) + val (title, shortTitle) = remember(kind, weekStart, weekEnd, locale, currentYear) { + kind.title(weekStart, weekEnd, locale, currentYear, abbreviated = false) to + kind.title(weekStart, weekEnd, locale, currentYear, abbreviated = true) } TopAppBar( title = { @@ -751,6 +817,7 @@ private fun WeekTopBar( private fun WeekDayHeader( days: List, today: LocalDate, + bias: Float, onOpenDay: (LocalDate) -> Unit, ) { val locale = currentLocale() @@ -766,8 +833,9 @@ private fun WeekDayHeader( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(12.dp)) - .clickable { onOpenDay(date) }, - horizontalAlignment = Alignment.CenterHorizontally, + .clickable { onOpenDay(date) } + .padding(start = HEADER_START_INSET * -bias), + horizontalAlignment = BiasAlignment.Horizontal(bias), ) { // One line, as the gutter cell beside it assumes (see WeekNumberCell). Text( @@ -1384,3 +1452,17 @@ private fun formatWeekTitle( forceYear = weekEnd.year != weekStart.year, ) } + +/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */ +private fun formatDayTitle( + date: LocalDate, + locale: Locale, + currentYear: Int, + abbreviated: Boolean = false, +): String = + formatCalendarTitle( + date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), + locale = locale, + currentYear = currentYear, + skeleton = if (abbreviated) "dMMM" else "EEEdMMM", + ) 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 fef5385..729db3c 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 @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -208,6 +209,20 @@ class MultiDayViewModel @Inject constructor( initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_COUNT) }, ) +/** The day view: the timeline one day at a time. */ +@HiltViewModel +class DayViewModel @Inject constructor( + repository: CalendarRepository, + settingsPrefs: SettingsPrefs, + @IoDispatcher io: CoroutineDispatcher, +) : PagedTimelineViewModel( + repository = repository, + settingsPrefs = settingsPrefs, + io = io, + gridRule = flowOf { _: LocalDate -> DayGrid }, + initialRule = { DayGrid }, +) + /** * 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; 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 8a7feee..d336cdf 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 @@ -50,6 +50,7 @@ internal fun WeekViewPreview( WeekSuccess( state = state, showWeekNumber = dayCount == 7, + headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f, scrollState = scrollState, allDayHeight = state.allDayStripHeight(), dragController = rememberTimelineDragController(), 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 deleted file mode 100644 index ac37f9b..0000000 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/day/DayPagingTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -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)) - } -} 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 2ac6c63..0480c9f 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 @@ -64,4 +64,18 @@ class WeekPagingTest { assertThat(grid.startForPage(page)).isEqualTo(date) assertThat(grid.startForPage(page - 1)).isEqualTo(LocalDate(1990, 5, 13)) } + + @Test + fun `a day page and its day round-trip`() { + listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day -> + assertThat(DayGrid.startForPage(DayGrid.pageFor(day))).isEqualTo(day) + } + } + + @Test + fun `adjacent day pages are adjacent days across a month end`() { + val page = DayGrid.pageFor(LocalDate(2026, 2, 28)) + assertThat(DayGrid.startForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1)) + assertThat(DayGrid.startForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27)) + } } From 8d3d28c240b708ac53bc9d3cafedce653afd872b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:18:13 +0200 Subject: [PATCH 11/17] Share one focused date across views (#184) --- .../calendula/ui/CalendarHost.kt | 19 ++++++--- .../calendula/ui/agenda/AgendaScreen.kt | 40 +++++++++++++++++- .../calendula/ui/common/ViewFocus.kt | 41 +++++++++++++++++++ .../calendula/ui/month/MonthScreen.kt | 12 +++++- .../calendula/ui/month/MonthViewModel.kt | 9 ---- .../calendula/ui/week/WeekPaging.kt | 12 ++++++ .../calendula/ui/week/WeekScreen.kt | 19 ++++++--- .../calendula/ui/week/WeekViewModel.kt | 20 +++++++-- .../ui/agenda/AgendaItemDatesTest.kt | 40 ++++++++++++++++++ .../calendula/ui/week/FocusOnPageTest.kt | 40 ++++++++++++++++++ 10 files changed, 224 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt 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 345c75a..5324f94 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -40,6 +40,9 @@ import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll +import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus +import de.jeanlucmakiola.calendula.ui.common.ViewFocus +import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory @@ -120,10 +123,11 @@ fun CalendarHost( val view = viewStack.last() val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) } - // Tapping a day in the month grid opens the day view anchored to that date. - var pendingDayIso by rememberSaveable { mutableStateOf(null) } + // The date every view opens on and carries along (#184); tapping a day + // anywhere focuses it and opens the day view there. + val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } val onOpenDay: (LocalDate) -> Unit = { date -> - pendingDayIso = date.toString() + focus.date = date viewStack = viewStack.drillToDay() } @@ -280,7 +284,7 @@ fun CalendarHost( // that would cover it, so the open doesn't land under Settings/form. dismissCoveringOverlays() createDateIso = null - pendingDayIso = req.dateIso + focus.date = LocalDate.parse(req.dateIso) // No widget source (an external date tap) roots over the default // home view, so backing out of the day returns home then exits. viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay() @@ -304,7 +308,7 @@ fun CalendarHost( // default home — so backing out returns to the default, then exits. dismissCoveringOverlays() createDateIso = null - pendingDayIso = null + focus.date = localToday() viewStack = viewBaseStack(defaultView, req.view ?: defaultView) onWidgetNavConsumed() } @@ -386,6 +390,7 @@ fun CalendarHost( CompositionLocalProvider( LocalEventMove provides moveScope, LocalTimelineScroll provides timelineScroll, + LocalViewFocus provides focus, // The failure state's way out when every calendar is switched off // (#239); nothing else in the calendar surfaces navigates here. LocalManageCalendars provides { showCalendars = true }, @@ -438,7 +443,6 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - initialDateIso = pendingDayIso, quickSwitchViews = quickSwitchViews, drawerViewOrder = drawerViewOrder, todayInToolbar = todayInToolbar, @@ -674,3 +678,6 @@ private val viewStackSaver = listSaver, Int>( save = { stack -> stack.map(CalendarView::ordinal) }, restore = { ordinals -> ordinals.map { CalendarView.entries[it] } }, ) + +private fun localToday(): LocalDate = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index ca64ec5..2386ce0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -1,5 +1,10 @@ package de.jeanlucmakiola.calendula.ui.agenda +import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus +import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus +import kotlinx.coroutines.flow.drop +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.rememberUpdatedState import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.morphEvent import androidx.compose.foundation.ExperimentalFoundationApi @@ -96,8 +101,15 @@ fun AgendaScreen( modifier: Modifier = Modifier, viewModel: AgendaViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsStateWithLifecycle() + // Opens on the focused date and hands back the day scrolled to the top. + val focus = LocalViewFocus.current + EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) } + val loadedState by viewModel.state.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle() + // Rows from before the anchor moved belong to another window: they would + // show the wrong days, and the view switch would match the wrong events. + val state = loadedState.takeUnless { it is AgendaUiState.Success && it.anchor != anchor } + ?: AgendaUiState.Loading val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() @@ -126,11 +138,12 @@ fun AgendaScreen( // requested: scrolling before the new rows compose leaves the list keyed to // a row that reappears further down, and it follows it there. LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) } - val jumpToToday = { + val jumpToToday: () -> Unit = { // Off today, moving the anchor is what resets the list (above); on today // the window doesn't change, so the scroll back is the whole action. if (isOnToday) scope.launch { listState.animateScrollToItem(0) } viewModel.goToToday() + (state as? AgendaUiState.Success)?.today?.let { focus?.date = it } } ModalNavigationDrawer( @@ -146,6 +159,7 @@ fun AgendaScreen( scope.launch { drawerState.close() } }, onJumpToDate = { target -> + focus?.date = target viewModel.goToDate(target) scope.launch { drawerState.close() } }, @@ -227,6 +241,7 @@ fun AgendaScreen( onRetry = viewModel::goToToday, onEventClick = onEventClick, onOpenDay = onOpenDay, + onTopDay = { focus?.date = it }, modifier = Modifier .weight(1f) .fillMaxWidth(), @@ -328,6 +343,7 @@ internal fun AgendaContent( onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), + onTopDay: ((LocalDate) -> Unit)? = null, ) { when (state) { AgendaUiState.Loading -> Box(modifier) @@ -370,6 +386,7 @@ internal fun AgendaContent( now = now, onEventClick = onEventClick, onOpenDay = onOpenDay, + onTopDay = onTopDay, modifier = modifier, ) } @@ -388,8 +405,20 @@ private fun AgendaList( now: Instant, onEventClick: (EventInstance) -> Unit, onOpenDay: (LocalDate) -> Unit, + onTopDay: ((LocalDate) -> Unit)?, modifier: Modifier = Modifier, ) { + if (onTopDay != null) { + val itemDates by rememberUpdatedState(agendaItemDates(days)) + val currentOnTopDay by rememberUpdatedState(onTopDay) + // The day the list opened on is where the view already is; only a + // scroll moves it on. + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .drop(1) + .collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) } + } + } LazyColumn( modifier = modifier, state = listState, @@ -432,6 +461,13 @@ private fun AgendaList( } } +/** The day of each item [AgendaList] lays out for [days], in its order. */ +internal fun agendaItemDates(days: List): List = + days.flatMap { day -> + // Header, the placeholder or the events, then the gap. + List(2 + day.events.size.coerceAtLeast(1)) { day.date } + } + @Composable private fun AgendaEmpty(modifier: Modifier = Modifier) { Column( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt new file mode 100644 index 0000000..74ac346 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt @@ -0,0 +1,41 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.datetime.LocalDate + +/** + * The date every calendar view is looking at (#184), so switching views keeps + * the same days on screen and what is shown in both can travel between them. + * + * A view reads it once, as it comes on screen, and writes it back only when the + * user moves: a view settling where it opened must not nudge it, or switching + * back and forth would drift. + */ +@Stable +class ViewFocus(initial: LocalDate) { + var date by mutableStateOf(initial) +} + +val ViewFocusSaver: Saver = Saver( + save = { it.date.toString() }, + restore = { ViewFocus(LocalDate.parse(it)) }, +) + +val LocalViewFocus = staticCompositionLocalOf { null } + +/** + * Runs [enter] with the focused date once, as the view comes on screen, before + * it reads its own position: its first frame is then already on that date. + */ +@Composable +fun EnterOnFocus(key: T, enter: (LocalDate) -> Unit) { + val focus = LocalViewFocus.current + remember(key) { focus?.let { enter(it.date) } } +} 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 c31a96f..d12529c 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 @@ -11,6 +11,8 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.rememberUpdatedState import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph +import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus +import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphEvent @@ -215,6 +217,9 @@ fun MonthScreen( modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), ) { + // Opens on the focused date's month, and hands its selection back as it moves. + val focus = LocalViewFocus.current + EnterOnFocus(viewModel) { viewModel.goToDate(it) } val state by viewModel.state.collectAsStateWithLifecycle() val continuousState by viewModel.continuousState.collectAsStateWithLifecycle() val month by viewModel.month.collectAsStateWithLifecycle() @@ -239,7 +244,7 @@ fun MonthScreen( // 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 anchorPage = monthIndexOf(month) val pagerState = key(scrolling) { rememberPagerState(initialPage = anchorPage) { continuousMonthCount() } } @@ -256,6 +261,11 @@ fun MonthScreen( LaunchedEffect(pagerState) { snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled) } + // The selection follows every move — paging, scrolling, today, a jump — so + // it is what the other views open on. Not the one this view opened with. + LaunchedEffect(viewModel) { + snapshotFlow { selectedDate }.drop(1).collect { focus?.date = it } + } val dense = viewStyle == MonthViewStyle.Dense // Today, from whichever state is driving; the clock only covers the first 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 04a5af2..eddb01a 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 @@ -94,15 +94,6 @@ 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 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 e9f43f6..e19ed1e 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 @@ -93,3 +93,15 @@ data object DayGrid : PageGrid { override fun pageFor(date: LocalDate): Int = (DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1) } + +/** + * Where the focused date goes when the pager settles on [page] (#184): kept if + * the page still shows it, otherwise moved by the pages travelled, so it keeps + * its place in the page — the same weekday a week on. + */ +internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate { + val start = startForPage(page) + val end = start.plus(dayCount - 1, DateTimeUnit.DAY) + if (focus in start..end) return focus + return focus.plus((page - pageFor(focus)) * dayCount, DateTimeUnit.DAY).coerceIn(start, end) +} 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 4e67dd3..a26c8eb 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 @@ -135,6 +135,8 @@ 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.EnterOnFocus +import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.TimelineGrounds import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey @@ -278,7 +280,6 @@ fun DayScreen( drawerViewOrder: List = IMPLEMENTED_VIEWS, todayInToolbar: Boolean = false, modifier: Modifier = Modifier, - initialDateIso: String? = null, viewModel: DayViewModel = hiltViewModel(), ) = PagedTimelineScreen( viewModel = viewModel, @@ -295,7 +296,6 @@ fun DayScreen( drawerViewOrder = drawerViewOrder, todayInToolbar = todayInToolbar, modifier = modifier, - initialDate = initialDateIso?.let(LocalDate::parse), ) /** What sets the timeline views apart; everything else they share. */ @@ -336,11 +336,14 @@ private fun PagedTimelineScreen( drawerViewOrder: List, todayInToolbar: Boolean, modifier: Modifier, - initialDate: LocalDate? = null, ) { - LaunchedEffect(initialDate) { initialDate?.let(viewModel::jumpTo) } + // Opens on the focused date, then carries it along as the pages move. + val focus = LocalViewFocus.current + EnterOnFocus(viewModel) { viewModel.jumpTo(it) } val state by viewModel.state.collectAsStateWithLifecycle() - val position by viewModel.position.collectAsStateWithLifecycle() + val anchor by viewModel.anchor.collectAsStateWithLifecycle() + val rule by viewModel.rule.collectAsStateWithLifecycle() + val position = remember(anchor, rule) { positionOf(anchor, rule) } val grid = position.grid val anchorPage = position.page val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() @@ -374,7 +377,10 @@ private fun PagedTimelineScreen( // 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) + snapshotFlow { pagerState.settledPage }.drop(1).collect { page -> + viewModel.onPageSettled(page) + focus?.let { it.date = grid.focusOnPage(it.date, page) } + } } // The page under the finger, so the title turns over mid-swipe, not after it. val weekStart = grid.startForPage(pagerState.currentPage) @@ -390,6 +396,7 @@ private fun PagedTimelineScreen( // 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 -> + focus?.date = target val page = grid.pageFor(target) if (grid is DaysGrid && grid.startForPage(page) != target) { viewModel.jumpTo(target) 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 729db3c..2acd4ef 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 @@ -56,6 +56,10 @@ 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) +/** Where [anchor] sits under [rule]. */ +fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition = + rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) } + /** * 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 @@ -90,14 +94,22 @@ abstract class PagedTimelineViewModel( // 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) + val anchor: StateFlow = _anchor - val position: StateFlow = - combine(_anchor, gridRule) { anchor, rule -> rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) } } + /** + * The grid for a given anchor, held eagerly so a screen can place its pager + * on its very first frame rather than a frame after the anchor moved. + */ + val rule: StateFlow<(LocalDate) -> PageGrid> = + gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule) + + private val position: StateFlow = + combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) } .distinctUntilChanged() .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000L), - initialValue = initialRule(todayDate).let { PagerPosition(it, it.pageFor(todayDate)) }, + initialValue = positionOf(todayDate, initialRule), ) /** @@ -141,7 +153,7 @@ abstract class PagedTimelineViewModel( /** The pager came to rest on [page]; follow it unless it is already the anchor's. */ fun onPageSettled(page: Int) { - val grid = position.value.grid + val grid = rule.value(_anchor.value) if (grid.pageFor(_anchor.value) != page) _anchor.value = grid.startForPage(page) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt new file mode 100644 index 0000000..4e5c7b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +/** The day each agenda list item belongs to, for the focus it hands back (#184). */ +class AgendaItemDatesTest { + + private val monday = LocalDate(2026, 9, 28) + private val tuesday = LocalDate(2026, 9, 29) + + private fun event(id: Long) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "Event", + start = Instant.fromEpochMilliseconds(0), + end = Instant.fromEpochMilliseconds(0), + isAllDay = false, + color = 0xFF000000.toInt(), + location = null, + ) + + @Test + fun `each day counts its header, events and gap`() { + val days = listOf(AgendaDay(monday, listOf(event(1), event(2))), AgendaDay(tuesday, listOf(event(3)))) + assertThat(agendaItemDates(days)).containsExactly( + monday, monday, monday, monday, + tuesday, tuesday, tuesday, + ).inOrder() + } + + @Test + fun `an empty day counts its placeholder`() { + assertThat(agendaItemDates(listOf(AgendaDay(monday, emptyList())))).hasSize(3) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt new file mode 100644 index 0000000..96e5025 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.calendula.ui.week + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +/** Where the focused date goes as a timeline pages (#184). */ +class FocusOnPageTest { + + // 2026-09-30 is a Wednesday. + private val wednesday = LocalDate(2026, 9, 30) + + @Test + fun `a page that still shows the focus keeps it`() { + val grid = WeekGrid(DayOfWeek.MONDAY) + assertThat(grid.focusOnPage(wednesday, grid.pageFor(wednesday))).isEqualTo(wednesday) + } + + @Test + fun `a week on keeps the weekday`() { + val grid = WeekGrid(DayOfWeek.MONDAY) + val page = grid.pageFor(wednesday) + assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 7)) + assertThat(grid.focusOnPage(wednesday, page - 2)).isEqualTo(LocalDate(2026, 9, 16)) + } + + @Test + fun `a run of days keeps the focus's column`() { + val grid = DaysGrid.startingAt(LocalDate(2026, 9, 29), 3) + val page = grid.pageFor(wednesday) + assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 3)) + } + + @Test + fun `a day page is the focus`() { + val page = DayGrid.pageFor(wednesday) + assertThat(DayGrid.focusOnPage(wednesday, page + 5)).isEqualTo(LocalDate(2026, 10, 5)) + } +} From 6ed0b30230bf5d401439de247802d3aa316f6e38 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:22:09 +0200 Subject: [PATCH 12/17] Lay timeline columns out through one geometry (#184) --- .../calendula/ui/common/BlockPlacement.kt | 69 ++++- .../calendula/ui/week/ColumnGeometry.kt | 169 +++++++++++ .../calendula/ui/week/WeekScreen.kt | 270 +++++++++++------- .../calendula/ui/week/ColumnGeometryTest.kt | 107 +++++++ 4 files changed, 498 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometry.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometryTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt index 4b1fcf7..d3b4566 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.Crossfade import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme @@ -13,15 +14,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ParentDataModifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import kotlin.math.roundToInt /** Gap a timed block leaves to its neighbours in the column. */ val BLOCK_OUTER_INSET = 1.dp @@ -224,9 +229,13 @@ fun BlockTimeLabel( } } -/** Where a timed block sits in its column, after tweening. */ +/** + * Where a timed block sits in its column, after tweening: across as shares of + * the column's width, so a column resizing carries its blocks with it (#184), + * down in the timeline's own units. + */ @Immutable -data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp) +data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp) /** * A timed block's placement, tweened rather than jumped. Continuity comes from @@ -239,17 +248,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp) */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement { - val spec: FiniteAnimationSpec = if (rememberReduceMotion() || - LocalTimelineZoom.current.isPinching - ) { - snap() - } else { - MaterialTheme.motionScheme.fastSpatialSpec() +fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement { + val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching + val dpSpec: FiniteAnimationSpec = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() + val shareSpec: FiniteAnimationSpec = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() + val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x") + val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width") + val animatedY by animateDpAsState(y, dpSpec, label = "block-y") + val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height") + return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight) +} + +/** Seats a block across its [placement]'s share of a [LaneColumn]. */ +fun Modifier.laneSlot(placement: BlockPlacement): Modifier = + then(LaneSlot(placement.xFraction, placement.widthFraction)) + +private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot +} + +/** + * A day column's blocks, each measured across the share of the column its + * [laneSlot] gives it; anything else fills the column. The shares are applied + * at layout time, so the column can change width every frame without its + * blocks recomposing. + */ +@Composable +fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Layout(content = content, modifier = modifier) { measurables, constraints -> + val width = constraints.maxWidth + val loose = constraints.copy(minWidth = 0, minHeight = 0) + val placed = measurables.map { measurable -> + val slot = measurable.parentData as? LaneSlot + if (slot == null) { + measurable.measure(loose) to 0 + } else { + val left = (slot.x * width).roundToInt() + val right = ((slot.x + slot.width) * width).roundToInt() + val laneWidth = (right - left).coerceAtLeast(0) + measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left + } + } + layout(width, constraints.maxHeight) { + placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) } + } } - val animatedX by animateDpAsState(x, spec, label = "block-x") - val animatedY by animateDpAsState(y, spec, label = "block-y") - val animatedWidth by animateDpAsState(width, spec, label = "block-width") - val animatedHeight by animateDpAsState(height, spec, label = "block-height") - return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometry.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometry.kt new file mode 100644 index 0000000..42a0ed4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometry.kt @@ -0,0 +1,169 @@ +package de.jeanlucmakiola.calendula.ui.week + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Dp +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.daysUntil +import kotlinx.datetime.plus +import kotlin.math.roundToInt + +/** A day column's place across the timeline, in pixels from its start edge. */ +@Immutable +data class ColumnSlot(val left: Float, val width: Float) { + val right: Float get() = left + width +} + +/** + * [date]'s column on a page showing this span across [width] pixels, with [gap] + * between columns. A date off the page carries on at the same pitch, off screen + * on its side — where a view switch slides it in from, or out to. + */ +fun DaySpan.slot(date: LocalDate, width: Float, gap: Float): ColumnSlot { + val pitch = (width + gap) / count + return ColumnSlot(left = start.daysUntil(date) * pitch, width = pitch - gap) +} + +/** The last day of the span. */ +val DaySpan.end: LocalDate get() = start.plus(count - 1, DateTimeUnit.DAY) + +/** + * The timeline's columns: one page's, or — while switching between the day, the + * multi-day and the week view (#184) — every day of both pages, each column + * between where it sits in the one and where it sits in the other. + * + * @param progress how far from [from] to [to], read at layout time only. + */ +@Stable +class ColumnGeometry(val from: DaySpan, val to: DaySpan, val progress: () -> Float) { + + /** Every day either page shows, in order: the two overlap, so they run on unbroken. */ + val dates: List = run { + val first = minOf(from.start, to.start) + val last = maxOf(from.end, to.end) + (0..first.daysUntil(last)).map { first.plus(it, DateTimeUnit.DAY) } + } + + fun slot(date: LocalDate, width: Float, gap: Float): ColumnSlot { + val a = from.slot(date, width, gap) + if (from == to) return a + val b = to.slot(date, width, gap) + val p = progress() + return ColumnSlot(lerp(a.left, b.left, p), lerp(a.width, b.width, p)) + } + + /** The span of columns [range] covers, between its extent on each page. */ + fun extent(fromRange: ClosedRange, toRange: ClosedRange, width: Float, gap: Float): ColumnSlot { + val a = from.extent(fromRange, width, gap) + if (from == to && fromRange == toRange) return a + val b = to.extent(toRange, width, gap) + val p = progress() + val left = lerp(a.left, b.left, p) + return ColumnSlot(left, lerp(a.right, b.right, p) - left) + } + + companion object { + /** One page's columns, standing still. */ + fun of(span: DaySpan): ColumnGeometry = ColumnGeometry(span, span) { 0f } + } +} + +private fun DaySpan.extent(range: ClosedRange, width: Float, gap: Float): ColumnSlot { + val left = slot(range.start, width, gap).left + return ColumnSlot(left, slot(range.endInclusive, width, gap).right - left) +} + +internal fun lerp(a: Float, b: Float, p: Float): Float = a + (b - a) * p + +/** + * Lays [content] out once per day of [geometry], each across its column. The + * columns are placed in the layout pass, so a view switch moving them every + * frame re-lays them out without recomposing them. Children fill the height + * the layout is given, or wrap it when it has none. + */ +@Composable +fun TimelineColumns( + geometry: ColumnGeometry, + gap: Dp, + modifier: Modifier = Modifier, + content: @Composable (LocalDate) -> Unit, +) { + val dates = geometry.dates + Layout( + content = { + dates.forEach { date -> + key(date) { Box(propagateMinConstraints = true) { content(date) } } + } + }, + modifier = modifier, + ) { measurables, constraints -> + val width = constraints.maxWidth + val gapPx = gap.toPx() + val slots = dates.map { geometry.slot(it, width.toFloat(), gapPx) } + val placeables = measurables.mapIndexed { i, measurable -> + val left = slots[i].left.roundToInt() + val columnWidth = (slots[i].right.roundToInt() - left).coerceAtLeast(0) + measurable.measure(constraints.copy(minWidth = columnWidth, maxWidth = columnWidth)) + } + val height = placeables.maxOfOrNull { it.height }?.coerceAtLeast(constraints.minHeight) + ?: constraints.minHeight + layout(width, height) { + placeables.forEachIndexed { i, placeable -> + placeable.placeRelative(slots[i].left.roundToInt(), 0) + } + } + } +} + +/** + * An all-day bar as each page lays it out: the days it covers there, clipped to + * the page, and its lane. A bar only one page has keeps its days on the other, + * so it slides out with them rather than vanishing. + */ +@Immutable +data class PlacedBar( + val event: EventInstance, + /** The day the bar is drawn from on the page it is tagged on, for the view morph. */ + val keyDate: LocalDate, + val fromRange: ClosedRange, + val toRange: ClosedRange, + val fromLane: Int, + val toLane: Int, +) + +/** [state]'s all-day bars, standing still. */ +internal fun staticBars(state: WeekUiState.Success): List = + state.allDaySpans.map { span -> + val range = state.days[span.startCol]..state.days[span.endCol] + PlacedBar(span.event, range.start, range, range, span.lane, span.lane) + } + +/** + * The all-day bars of both pages of a view switch, each once: matched by + * occurrence, placed on each page as that page lays it out. + */ +internal fun morphBars(from: WeekUiState.Success?, to: WeekUiState.Success?): List { + val fromBars = from?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() } + val toBars = to?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() } + return (fromBars.keys + toBars.keys).map { occurrence -> + val a = fromBars[occurrence] + val b = toBars[occurrence] + PlacedBar( + event = (a ?: b)!!.event, + keyDate = (a ?: b)!!.keyDate, + fromRange = (a ?: b)!!.fromRange, + toRange = (b ?: a)!!.toRange, + fromLane = (a ?: b)!!.fromLane, + toLane = (b ?: a)!!.toLane, + ) + } +} + +private fun EventInstance.occurrence(): Pair = eventId to start.toEpochMilliseconds() 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 a26c8eb..5599dc9 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 @@ -68,6 +68,11 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.laneSlot +import de.jeanlucmakiola.calendula.ui.common.LaneColumn +import kotlin.math.roundToInt +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.layout.Layout import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -670,7 +675,8 @@ private fun WeekFrame( ) } } - Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() } + // Clipped, as the pager was: a view switch slides columns in past its edges. + Box(modifier = Modifier.weight(1f).fillMaxHeight().clipToBounds()) { pages() } } } @@ -728,6 +734,47 @@ private fun WeekPage( onOpenDay: (LocalDate) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, +) { + val geometry = remember(state.weekStart, state.days.size) { + ColumnGeometry.of(DaySpan(state.weekStart, state.days.size)) + } + val bars = remember(state) { staticBars(state) } + TimelinePageContent( + geometry = geometry, + today = state.today, + timedFor = { state.timedByDay[it].orEmpty() }, + bars = bars, + headerBias = headerBias, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = active, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) +} + +/** + * A page's header, all-day strip and day columns, laid out over [geometry]: a + * single page's, or both pages' of a view switch mid-way (#184). + */ +@Composable +private fun TimelinePageContent( + geometry: ColumnGeometry, + today: LocalDate, + timedFor: (LocalDate) -> List, + bars: List, + headerBias: Float, + 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( @@ -735,18 +782,20 @@ private fun WeekPage( .fillMaxWidth() .background(MaterialTheme.colorScheme.surface), ) { - WeekDayHeader(days = state.days, today = state.today, bias = headerBias, onOpenDay = onOpenDay) + WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = onOpenDay) // This strip is painted on `surface`, so an outlined chip has to // fill with that and not with the columns' container (#230). CompositionLocalProvider( LocalChipGround provides MaterialTheme.colorScheme.surface, ) { - AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) + AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, onEventClick = onEventClick) } } Spacer(Modifier.height(TIMELINE_TOP_GAP)) Timeline( - state = state, + geometry = geometry, + today = today, + timedFor = timedFor, allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, @@ -822,65 +871,64 @@ private fun WeekTopBar( @Composable private fun WeekDayHeader( - days: List, + geometry: ColumnGeometry, today: LocalDate, bias: Float, onOpenDay: (LocalDate) -> Unit, ) { val locale = currentLocale() - Row( + TimelineColumns( + geometry = geometry, + gap = 0.dp, modifier = Modifier .fillMaxWidth() .padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET), - ) { - days.forEach { date -> - val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) - val isToday = date == today - Column( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(12.dp)) - .clickable { onOpenDay(date) } - .padding(start = HEADER_START_INSET * -bias), - horizontalAlignment = BiasAlignment.Horizontal(bias), + ) { date -> + val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) + val isToday = date == today + Column( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { onOpenDay(date) } + .padding(start = HEADER_START_INSET * -bias), + horizontalAlignment = BiasAlignment.Horizontal(bias), + ) { + // 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 circle slot so the header height is + // identical whether or not the week contains today. + Box( + modifier = Modifier.size(DATE_SLOT_SIZE), + contentAlignment = Alignment.Center, ) { - // 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 circle slot so the header height is - // identical whether or not the week contains today. - Box( - modifier = Modifier.size(DATE_SLOT_SIZE), - contentAlignment = Alignment.Center, - ) { - if (isToday) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.morphDay(date).fillMaxSize(), - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = date.day.toString(), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - } + if (isToday) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.morphDay(date).fillMaxSize(), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) } - } else { - Text( - text = date.day.toString(), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.morphDay(date), - ) } + } else { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.morphDay(date), + ) } } } @@ -936,7 +984,8 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { @Composable private fun AllDayStrip( - state: WeekUiState.Success, + geometry: ColumnGeometry, + bars: List, height: Dp, onEventClick: (EventInstance) -> Unit, ) { @@ -957,29 +1006,39 @@ private fun AllDayStrip( // 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. - BoxWithConstraints( + Layout( + content = { + bars.forEach { bar -> + key(bar.event.eventId, bar.event.start) { + AllDayBar( + event = bar.event, + dark = dark, + onClick = { onEventClick(bar.event) }, + modifier = Modifier + .morphEvent(ViewMorphKey.event(bar.event, bar.keyDate)) + .padding(horizontal = 1.dp, vertical = 1.dp), + ) + } + } + }, modifier = Modifier .weight(1f) .fillMaxHeight() .clipToBounds(), - ) { - val colWidth = maxWidth / state.days.size - state.allDaySpans.forEach { span -> - val spanCols = span.endCol - span.startCol + 1 - AllDayBar( - event = span.event, - dark = dark, - onClick = { onEventClick(span.event) }, - modifier = Modifier - .offset( - x = colWidth * span.startCol, - y = ALL_DAY_ROW_HEIGHT * span.lane, - ) - .morphEvent(ViewMorphKey.event(span.event, state.days[span.startCol])) - .width(colWidth * spanCols) - .height(ALL_DAY_ROW_HEIGHT) - .padding(horizontal = 1.dp, vertical = 1.dp), - ) + ) { measurables, constraints -> + val width = constraints.maxWidth.toFloat() + val rowPx = ALL_DAY_ROW_HEIGHT.toPx() + val progress = geometry.progress() + val placed = measurables.mapIndexed { i, measurable -> + val bar = bars[i] + val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f) + val left = extent.left.roundToInt() + val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0) + val y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt() + Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y) + } + layout(constraints.maxWidth, constraints.maxHeight) { + placed.forEach { (placeable, x, y) -> placeable.placeRelative(x, y) } } } } @@ -1020,7 +1079,9 @@ private fun AllDayBar( @Composable private fun Timeline( - state: WeekUiState.Success, + geometry: ColumnGeometry, + today: LocalDate, + timedFor: (LocalDate) -> List, allDayHeight: Dp, scrollState: ScrollState, dragController: TimelineDragController, @@ -1052,8 +1113,14 @@ private fun Timeline( // 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, fitHeight, hourHeight, zoom) + // What a block's text is fitted to: the column it will end up in once a + // view switch is past half way, so it reflows once rather than per frame. + val textColumns by remember(geometry) { + derivedStateOf { if (geometry.progress() >= 0.5f) geometry.to.count else geometry.from.count } + } + val columnWidth = (maxWidth - TIMELINE_CONTENT_END_INSET + COLUMN_GAP) / textColumns - COLUMN_GAP TimelineGrounds( - days = state.days, + days = geometry.dates, scrollState = scrollState, hourHeight = hourHeight, columnGap = COLUMN_GAP, @@ -1075,7 +1142,9 @@ private fun Timeline( .verticalScroll(scrollState) .onGloballyPositioned { if (active) dragController.geometry.viewport = it }, ) { - Row( + TimelineColumns( + geometry = geometry, + gap = COLUMN_GAP, modifier = Modifier .fillMaxWidth() .height(totalHeight) @@ -1091,29 +1160,25 @@ private fun Timeline( 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.columnWidthPx = (coords.size.width + gap) / geometry.to.count + it.days = geometry.dates 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(), - ) - } + ) { day -> + DayColumnCard( + blocks = timedFor(day), + dark = dark, + date = day, + today = today, + hourHeight = hourHeight, + columnWidth = columnWidth, + dragController = dragController, + onEventClick = onEventClick, + onCreateAt = onCreateAt, + onDrop = onDrop, + modifier = Modifier.fillMaxHeight(), + ) } } } @@ -1126,6 +1191,7 @@ private fun DayColumnCard( date: LocalDate, today: LocalDate, hourHeight: Dp, + columnWidth: Dp, dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, @@ -1162,7 +1228,7 @@ private fun DayColumnCard( ), modifier = modifier, ) { - BoxWithConstraints( + LaneColumn( modifier = Modifier .fillMaxSize() // The hour cells sit over the column background but under the @@ -1179,7 +1245,6 @@ private fun DayColumnCard( } }, ) { - val colWidth = maxWidth val minEventHeight = hourHeight * MIN_EVENT_FRACTION // Keyed by event, so a block that changes time or lane is the *same* // composable afterwards and tweens there. The ordinal disambiguates @@ -1188,14 +1253,13 @@ private fun DayColumnCard( blocks.forEach { block -> val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 key(block.event.eventId, ordinal) { - val laneWidth = colWidth / block.laneCount val top = hourHeight * (block.startMin / 60f) val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight val place = animatedBlockPlacement( - x = laneWidth * block.lane, + xFraction = block.lane.toFloat() / block.laneCount, + widthFraction = 1f / block.laneCount, y = top, - width = laneWidth, height = height, ) // Where this block is cut at midnight, which decides both @@ -1214,7 +1278,7 @@ private fun DayColumnCard( block = block, dark = dark, height = blockHeight, - width = place.width, + width = columnWidth / block.laneCount, date = date, cuts = cuts, topInset = topInset, @@ -1222,9 +1286,9 @@ private fun DayColumnCard( onClick = { onEventClick(block.event) }, onDrop = onDrop, modifier = Modifier - .offset(x = place.x, y = place.y + topInset) + .laneSlot(place) + .offset(y = place.y + topInset) .morphEvent(ViewMorphKey.event(block.event, date)) - .width(place.width) .height(blockHeight) .padding(horizontal = BLOCK_OUTER_INSET), ) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometryTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometryTest.kt new file mode 100644 index 0000000..9dd729c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometryTest.kt @@ -0,0 +1,107 @@ +package de.jeanlucmakiola.calendula.ui.week + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +/** The timeline's columns, standing still and mid view switch (#184). */ +class ColumnGeometryTest { + + // 2026-09-27 is a Sunday; the week runs Sun..Sat. + private val sunday = LocalDate(2026, 9, 27) + private val week = DaySpan(sunday, 7) + private val monday = sunday.plus(1, DateTimeUnit.DAY) + private val day = DaySpan(monday, 1) + private val width = 700f + private val gap = 7f + + @Test + fun `a page's columns follow the pitch`() { + val slot = week.slot(sunday.plus(3, DateTimeUnit.DAY), width, gap) + assertThat(slot.left).isEqualTo(3 * 101f) + assertThat(slot.width).isEqualTo(94f) + } + + @Test + fun `the ends of a switch are the two pages`() { + var p = 0f + val geometry = ColumnGeometry(week, day) { p } + assertThat(geometry.slot(monday, width, gap)).isEqualTo(week.slot(monday, width, gap)) + p = 1f + assertThat(geometry.slot(monday, width, gap)).isEqualTo(day.slot(monday, width, gap)) + } + + @Test + fun `days off the target page end off screen on their side`() { + val geometry = ColumnGeometry(week, day) { 1f } + assertThat(geometry.slot(sunday, width, gap).right).isAtMost(0f) + assertThat(geometry.slot(monday.plus(1, DateTimeUnit.DAY), width, gap).left).isAtLeast(width) + } + + @Test + fun `a switch covers both pages without a break`() { + // Focus on Saturday: the six-day run starting there reaches into next week. + val saturday = sunday.plus(6, DateTimeUnit.DAY) + val geometry = ColumnGeometry(week, DaySpan(saturday, 6)) { 0f } + assertThat(geometry.dates).hasSize(12) + assertThat(geometry.dates.first()).isEqualTo(sunday) + assertThat(geometry.dates.zipWithNext().all { (a, b) -> b == a.plus(1, DateTimeUnit.DAY) }).isTrue() + } + + @Test + fun `a bar spans its extent on each page`() { + val tuesday = monday.plus(1, DateTimeUnit.DAY) + val range = monday..tuesday + val geometry = ColumnGeometry(week, day) { 0f } + val extent = geometry.extent(range, monday..monday, width, 0f) + assertThat(extent.left).isEqualTo(week.slot(monday, width, 0f).left) + assertThat(extent.right).isEqualTo(week.slot(tuesday, width, 0f).right) + } + + @Test + fun `a bar only one page has keeps its days on the other`() { + val bar = AllDaySpan(event(1), startCol = 2, endCol = 3, lane = 1) + val from = state(week, listOf(bar)) + val to = state(day, emptyList()) + val placed = morphBars(from, to).single() + assertThat(placed.fromRange).isEqualTo(placed.toRange) + assertThat(placed.toLane).isEqualTo(1) + } + + @Test + fun `a bar on both pages is placed once, clipped to each`() { + val event = event(1) + val from = state(week, listOf(AllDaySpan(event, startCol = 0, endCol = 6, lane = 0))) + val to = state(day, listOf(AllDaySpan(event, startCol = 0, endCol = 0, lane = 0))) + val placed = morphBars(from, to).single() + assertThat(placed.fromRange).isEqualTo(sunday..sunday.plus(6, DateTimeUnit.DAY)) + assertThat(placed.toRange).isEqualTo(monday..monday) + } + + private fun state(span: DaySpan, bars: List): WeekUiState.Success { + val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) } + return WeekUiState.Success( + weekStart = span.start, + today = sunday, + days = days, + allDaySpans = bars, + timedByDay = emptyMap(), + ) + } + + private fun event(id: Long) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "Event", + start = Instant.fromEpochMilliseconds(0), + end = Instant.fromEpochMilliseconds(0), + isAllDay = true, + color = 0xFF000000.toInt(), + location = null, + ) +} From 7d7d16ca3129c7f334449ccce051d4797ad6fd0e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:26:24 +0200 Subject: [PATCH 13/17] Resize timeline columns in place when switching views (#184) --- .../calendula/ui/CalendarHost.kt | 58 ++-- .../ui/common/CalendarTitleButton.kt | 29 +- .../calendula/ui/common/CalendarView.kt | 10 + .../calendula/ui/common/ViewSwitcherPill.kt | 20 +- .../calendula/ui/week/TimelineMorph.kt | 27 ++ .../calendula/ui/week/WeekScreen.kt | 321 ++++++++++++------ .../calendula/ui/common/ViewScreenTest.kt | 20 ++ 7 files changed, 341 insertions(+), 144 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewScreenTest.kt 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 5324f94..343145e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -20,6 +20,9 @@ import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.screen +import de.jeanlucmakiola.calendula.ui.common.ViewScreen +import androidx.compose.runtime.SideEffect import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity @@ -53,7 +56,6 @@ import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.selectView import de.jeanlucmakiola.calendula.ui.common.viewBaseStack -import de.jeanlucmakiola.calendula.ui.week.DayScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.edit.ImportSource @@ -61,8 +63,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 de.jeanlucmakiola.calendula.ui.week.TimelineScreen import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -121,6 +122,13 @@ fun CalendarHost( mutableStateOf(listOf(defaultView)) } val view = viewStack.last() + // The timeline view last shown, so the timeline leaving for another screen + // keeps drawing the view it was on while it goes. + var heldTimeline by rememberSaveable { + mutableStateOf(if (view.screen == ViewScreen.Timeline) view else CalendarView.Week) + } + val timelineView = if (view.screen == ViewScreen.Timeline) view else heldTimeline + SideEffect { heldTimeline = timelineView } val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) } // The date every view opens on and carries along (#184); tapping a day @@ -400,20 +408,23 @@ fun CalendarHost( .fillMaxSize() .onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y }, ) { + // Keyed on the screen, not the view: the day, multi-day and week views + // are one timeline that resizes between them itself (#184), so switching + // among them must not replay this transition. AnimatedContent( - targetState = view, + targetState = view.screen, transitionSpec = { viewSwitch }, label = "view-switch", - ) { currentView -> + ) { screen -> val sharedScope = this@SharedTransitionLayout val visibilityScope = this@AnimatedContent val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip) { if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip) } CompositionLocalProvider(LocalViewMorph provides viewMorph) { - when (currentView) { - CalendarView.Week -> WeekScreen( - selectedView = currentView, + when (screen) { + ViewScreen.Timeline -> TimelineScreen( + view = timelineView, onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, @@ -424,8 +435,8 @@ fun CalendarHost( drawerViewOrder = drawerViewOrder, todayInToolbar = todayInToolbar, ) - CalendarView.MultiDay -> MultiDayScreen( - selectedView = currentView, + ViewScreen.Month -> MonthScreen( + selectedView = CalendarView.Month, onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, @@ -436,31 +447,8 @@ fun CalendarHost( drawerViewOrder = drawerViewOrder, todayInToolbar = todayInToolbar, ) - CalendarView.Day -> DayScreen( - selectedView = currentView, - onSelectView = onSelectView, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - ) - CalendarView.Month -> MonthScreen( - selectedView = currentView, - onSelectView = onSelectView, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - ) - CalendarView.Agenda -> AgendaScreen( - selectedView = currentView, + ViewScreen.Agenda -> AgendaScreen( + selectedView = CalendarView.Agenda, onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt index 305f0ec..bdd9e9a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt @@ -1,5 +1,10 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row @@ -71,12 +76,24 @@ fun CalendarTitleButton( availableWidth = constraints.maxWidth, ) } - Text( - text = shown, - style = style, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + // Cross-fades, so switching views or paging turns the title over + // rather than snapping it (#184). + val fadeSpec = rememberCalendarFadeSpec() + AnimatedContent( + targetState = shown, + transitionSpec = { + (fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false)) + }, + contentAlignment = Alignment.CenterStart, + label = "calendar-title", + ) { text -> + Text( + text = text, + style = style, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } Icon( imageVector = Icons.Default.ArrowDropDown, 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 01033d9..9f756de 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 @@ -129,3 +129,13 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List.drillToDay(): List = if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day + +/** The screens the views are drawn by: the day, multi-day and week views share one timeline (#184). */ +enum class ViewScreen { Month, Timeline, Agenda } + +val CalendarView.screen: ViewScreen + get() = when (this) { + CalendarView.Month -> ViewScreen.Month + CalendarView.Agenda -> ViewScreen.Agenda + CalendarView.Week, CalendarView.MultiDay, CalendarView.Day -> ViewScreen.Timeline + } 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 10e17c4..3ca60cd 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 @@ -1,10 +1,16 @@ package de.jeanlucmakiola.calendula.ui.common +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.padding import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -30,11 +36,23 @@ fun ViewSwitcherPill( trailingInset: Dp = AppBarSpacing.ContainerTrailingInset, ) { if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return + val fadeSpec = rememberCalendarFadeSpec() FilledTonalButton( onClick = onCycle, shape = MaterialTheme.shapes.large, modifier = modifier.padding(end = trailingInset), ) { - Text(current.label()) + // The label cross-fades and the pill eases to its width, so cycling + // views turns it over rather than snapping it (#184). + AnimatedContent( + targetState = current.label(), + transitionSpec = { + (fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false)) + }, + contentAlignment = Alignment.Center, + label = "view-pill", + ) { label -> + Text(label) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt new file mode 100644 index 0000000..35c43cf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt @@ -0,0 +1,27 @@ +package de.jeanlucmakiola.calendula.ui.week + +import androidx.compose.animation.core.Animatable +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.LocalDate + +/** + * A switch between two timeline views in progress (#184): the page on screen + * when it started, the page it ends on, and how far between the two it is. + */ +@Stable +internal class TimelineMorph( + val fromKind: TimelineKind, + val toKind: TimelineKind, + val from: DaySpan, + val to: DaySpan, + val fromState: StateFlow, + val toState: StateFlow, +) { + val progress = Animatable(0f) + + /** Where each day's events come from: the page it was on, else the page it goes to. */ + fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To + + enum class Side { From, To } +} 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 5599dc9..8b00def 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 @@ -68,6 +68,13 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.first +import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.SideEffect import de.jeanlucmakiola.calendula.ui.common.laneSlot import de.jeanlucmakiola.calendula.ui.common.LaneColumn import kotlin.math.roundToInt @@ -211,9 +218,13 @@ internal fun WeekUiState.Success.allDayStripHeight(): Dp { return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 } +/** + * The day, multi-day (#193) and week views: one timeline whose columns resize + * in place when switching between them (#184), rather than three screens. + */ @Composable -fun WeekScreen( - selectedView: CalendarView, +fun TimelineScreen( + view: CalendarView, onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, @@ -224,84 +235,41 @@ fun WeekScreen( drawerViewOrder: List = IMPLEMENTED_VIEWS, todayInToolbar: Boolean = false, modifier: Modifier = Modifier, - viewModel: WeekViewModel = hiltViewModel(), -) = PagedTimelineScreen( - viewModel = viewModel, - kind = TimelineKind.Week, - selectedView = selectedView, - onSelectView = onSelectView, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - modifier = modifier, -) + weekViewModel: WeekViewModel = hiltViewModel(), + multiDayViewModel: MultiDayViewModel = hiltViewModel(), + dayViewModel: DayViewModel = hiltViewModel(), +) { + val viewModels = remember(weekViewModel, multiDayViewModel, dayViewModel) { + TimelineViewModels(weekViewModel, multiDayViewModel, dayViewModel) + } + PagedTimelineScreen( + view = view, + viewModels = viewModels, + 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, - kind = TimelineKind.MultiDay, - selectedView = selectedView, - onSelectView = onSelectView, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - modifier = modifier, -) - -/** The day view: the week view's timeline one day at a time. */ -@Composable -fun DayScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> 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: DayViewModel = hiltViewModel(), -) = PagedTimelineScreen( - viewModel = viewModel, - kind = TimelineKind.Day, - selectedView = selectedView, - onSelectView = onSelectView, - // Tapping the one column's header would only reopen this day. - onOpenDay = {}, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - modifier = modifier, -) +/** Each timeline view's own pages and anchor, held side by side so a switch can show both. */ +internal class TimelineViewModels( + private val week: WeekViewModel, + private val multiDay: MultiDayViewModel, + private val day: DayViewModel, +) { + operator fun get(view: CalendarView): PagedTimelineViewModel = when (view) { + CalendarView.MultiDay -> multiDay + CalendarView.Day -> day + else -> week + } +} /** What sets the timeline views apart; everything else they share. */ internal enum class TimelineKind( @@ -325,12 +293,21 @@ internal enum class TimelineKind( } } +internal val CalendarView.timelineKind: TimelineKind + get() = when (this) { + CalendarView.MultiDay -> TimelineKind.MultiDay + CalendarView.Day -> TimelineKind.Day + else -> TimelineKind.Week + } + +/** How long a switch waits for the view it goes to before resizing into it without its events. */ +private const val MORPH_DATA_WAIT_MS = 150L + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PagedTimelineScreen( - viewModel: PagedTimelineViewModel, - kind: TimelineKind, - selectedView: CalendarView, + view: CalendarView, + viewModels: TimelineViewModels, onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, @@ -342,10 +319,23 @@ private fun PagedTimelineScreen( todayInToolbar: Boolean, modifier: Modifier, ) { + // The view on screen, which trails [view] while the columns resize into it. + var shown by remember { mutableStateOf(view) } + var morph by remember { mutableStateOf(null) } + // The strip height a switch ended on, for the pager it hands over to. + var handoffStrip by remember { mutableStateOf(null) } + val viewModel = viewModels[shown] + val kind = shown.timelineKind + // Opens on the focused date, then carries it along as the pages move. val focus = LocalViewFocus.current EnterOnFocus(viewModel) { viewModel.jumpTo(it) } - val state by viewModel.state.collectAsStateWithLifecycle() + val loadedState by viewModel.state.collectAsStateWithLifecycle() + // A view not shown before has nothing loaded yet; the one before it stands + // in, as its pages load their own days anyway, rather than a skeleton. + var lastState by remember { mutableStateOf(WeekUiState.Loading) } + val state = if (loadedState is WeekUiState.Loading) lastState else loadedState + SideEffect { if (loadedState !is WeekUiState.Loading) lastState = loadedState } val anchor by viewModel.anchor.collectAsStateWithLifecycle() val rule by viewModel.rule.collectAsStateWithLifecycle() val position = remember(anchor, rule) { positionOf(anchor, rule) } @@ -364,9 +354,9 @@ private fun PagedTimelineScreen( val scope = rememberCoroutineScope() // 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) { + // opened straight on the anchor's page: another view, the first day of the + // week changing, or a multi-day jump moving another date into the first column. + val pagerState = key(viewModel, grid) { rememberPagerState(initialPage = anchorPage) { grid.pageCount } } val pageSpec = rememberCalendarPageSpec() @@ -387,9 +377,52 @@ private fun PagedTimelineScreen( focus?.let { it.date = grid.focusOnPage(it.date, page) } } } - // 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) + + // Switching to another timeline view resizes the columns into it (#184): + // from the page on screen to the target view's page around the focused + // date, then hands over to that view's pager at the page the resize ended on. + val currentShown by rememberUpdatedState(shown) + val currentPager by rememberUpdatedState(pagerState) + val currentGrid by rememberUpdatedState(grid) + val currentState by rememberUpdatedState(state) + LaunchedEffect(view) { + // A switch interrupted by another lands first, and this one starts from there. + snapshotFlow { morph }.first { it == null } + withFrameNanos { } + if (view == currentShown) return@LaunchedEffect + val from = currentShown + val fromGrid = currentGrid + val fromSpan = DaySpan(fromGrid.startForPage(currentPager.currentPage), fromGrid.dayCount) + val target = (focus?.date ?: fromSpan.start).coerceIn(fromSpan.start, fromSpan.end) + focus?.date = target + val toModel = viewModels[view] + toModel.jumpTo(target) + val to = positionOf(target, toModel.rule.value) + val toSpan = DaySpan(to.grid.startForPage(to.page), to.grid.dayCount) + val fromLoaded = viewModels[from].span(fromSpan) + val toLoaded = toModel.span(toSpan) + if (reduceMotion || currentState !is WeekUiState.Success) { + shown = view + return@LaunchedEffect + } + val switch = TimelineMorph(from.timelineKind, view.timelineKind, fromSpan, toSpan, fromLoaded, toLoaded) + try { + withTimeoutOrNull(MORPH_DATA_WAIT_MS) { toLoaded.first { it !is WeekUiState.Loading } } + morph = switch + switch.progress.animateTo(1f, pageSpec) + } finally { + handoffStrip = (toLoaded.value as? WeekUiState.Success)?.allDayStripHeight() + shown = view + morph = null + } + } + + // The page under the finger, so the title turns over mid-swipe, not after + // it; mid-switch, the page being switched to. + val weekStart = morph?.to?.start ?: grid.startForPage(pagerState.currentPage) + val titleKind = morph?.toKind ?: kind + val dayCount = morph?.to?.count ?: grid.dayCount + val weekEnd = weekStart.plus(dayCount - 1, kotlinx.datetime.DateTimeUnit.DAY) // 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. @@ -417,12 +450,12 @@ private fun PagedTimelineScreen( gesturesEnabled = drawerState.isOpen, drawerContent = { CalendarDrawer( - currentView = selectedView, + currentView = view, currentDate = weekStart, drawerState = drawerState, viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) + onSelectView = { target -> + onSelectView(target) scope.launch { drawerState.close() } }, onJumpToDate = { target -> @@ -440,12 +473,12 @@ private fun PagedTimelineScreen( modifier = modifier, topBar = { WeekTopBar( - kind = kind, + kind = titleKind, weekStart = weekStart, weekEnd = weekEnd, currentYear = today.year, - selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, + selectedView = view, + onCycleView = { onSelectView(view.next(quickSwitchViews)) }, quickSwitchViews = quickSwitchViews, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, @@ -457,10 +490,10 @@ private fun PagedTimelineScreen( floatingActionButton = { CalendarFabColumn( todayVisible = !isOnCurrentWeek && !todayInToolbar, - todayText = stringResource(kind.todayText), + todayText = stringResource(titleKind.todayText), onToday = jumpToToday, onCreate = { - // Anchor on today when it's in view, else the week's first day. + // Anchor on today when it's in view, else the page's first day. onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) }, ) @@ -472,6 +505,8 @@ private fun PagedTimelineScreen( pagerState = pagerState, grid = grid, kind = kind, + morph = morph, + handoffStrip = handoffStrip, span = viewModel::span, onRetry = viewModel::goToToday, onEventClick = onEventClick, @@ -492,6 +527,8 @@ private fun WeekContent( pagerState: PagerState, grid: PageGrid, kind: TimelineKind, + morph: TimelineMorph?, + handoffStrip: Dp?, span: (DaySpan) -> StateFlow, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, @@ -536,13 +573,24 @@ private fun WeekContent( when (s) { WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount) is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is WeekUiState.Success -> WeekPager( + is WeekUiState.Success -> if (morph != null) { + TimelineMorphFrame( + morph = morph, + today = s.today, + scrollState = scrollState, + dragController = dragController, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } else WeekPager( pagerState = pagerState, grid = grid, kind = kind, span = span, today = s.today, - initialStripHeight = s.allDayStripHeight(), + initialStripHeight = handoffStrip ?: s.allDayStripHeight(), scrollState = scrollState, dragController = dragController, onEventClick = onEventClick, @@ -637,6 +685,70 @@ private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success { ) } +/** + * The timeline mid-switch between two views (#184): every day of both pages, + * each column between its place on the one and on the other, with no pager + * under them. It starts and ends exactly as the two pages lay out, so the + * pagers either side hand over without a jump. + */ +@Composable +private fun TimelineMorphFrame( + morph: TimelineMorph, + today: LocalDate, + scrollState: ScrollState, + dragController: TimelineDragController, + onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, +) { + val fromState by morph.fromState.collectAsStateWithLifecycle() + val toState by morph.toState.collectAsStateWithLifecycle() + val from = fromState as? WeekUiState.Success + val to = toState as? WeekUiState.Success + val geometry = remember(morph) { ColumnGeometry(morph.from, morph.to) { morph.progress.value } } + val bars = remember(from, to) { morphBars(from, to) } + val timedFor: (LocalDate) -> List = remember(morph, from, to) { + { date -> + val side = if (morph.sourceOf(date) == TimelineMorph.Side.From) from else to + side?.timedByDay?.get(date).orEmpty() + } + } + val progress = morph.progress.value + val fromStrip = from?.allDayStripHeight() ?: 0.dp + val toStrip = to?.allDayStripHeight() ?: fromStrip + val allDayHeight = androidx.compose.ui.unit.lerp(fromStrip, toStrip, progress.coerceIn(0f, 1f)) + val weekNumbers = listOf(morph.fromKind, morph.toKind).map { if (it.showWeekNumber) 1f else 0f } + val weekNumberAlpha = lerp(weekNumbers[0], weekNumbers[1], progress).coerceIn(0f, 1f) + // The events travel inside the columns here, not between views. + CompositionLocalProvider(LocalViewMorph provides null) { + WeekFrame( + weekStart = if (morph.toKind.showWeekNumber) morph.to.start else morph.from.start, + showWeekNumber = weekNumberAlpha > 0f, + allDayHeight = allDayHeight, + weekNumberAlpha = weekNumberAlpha, + scrollState = scrollState, + dragController = dragController, + ) { + TimelinePageContent( + geometry = geometry, + today = today, + timedFor = timedFor, + bars = bars, + headerBias = lerp(morph.fromKind.headerBias, morph.toKind.headerBias, progress.coerceIn(0f, 1f)), + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = false, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } + } +} + /** * What stays put while the weeks swipe: the week-number badge and the hour * gutter, with [pages] filling the rest. @@ -646,6 +758,7 @@ private fun WeekFrame( weekStart: LocalDate, showWeekNumber: Boolean, allDayHeight: Dp, + weekNumberAlpha: Float = 1f, scrollState: ScrollState, dragController: TimelineDragController, pages: @Composable () -> Unit, @@ -657,7 +770,11 @@ private fun WeekFrame( .fillMaxWidth() .background(MaterialTheme.colorScheme.surface), ) { - WeekNumberCell(weekStart, showBadge = showWeekNumber) + WeekNumberCell( + weekStart = weekStart, + showBadge = showWeekNumber, + modifier = if (weekNumberAlpha < 1f) Modifier.alpha(weekNumberAlpha) else Modifier, + ) Spacer(Modifier.height(allDayHeight)) } Spacer(Modifier.height(TIMELINE_TOP_GAP)) @@ -942,10 +1059,10 @@ private fun WeekDayHeader( * bar's hamburger (see GUTTER_CONTENT_START_INSET). */ @Composable -private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean) { +private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) { val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding( start = GUTTER_CONTENT_START_INSET, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewScreenTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewScreenTest.kt new file mode 100644 index 0000000..538c7da --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewScreenTest.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** Which screen draws each view: the timeline views share one, so switching among them resizes it (#184). */ +class ViewScreenTest { + + @Test + fun `the day, multi-day and week views share the timeline`() { + assertThat(listOf(CalendarView.Day, CalendarView.MultiDay, CalendarView.Week).map { it.screen }.toSet()) + .containsExactly(ViewScreen.Timeline) + } + + @Test + fun `month and agenda each have their own screen`() { + assertThat(CalendarView.Month.screen).isEqualTo(ViewScreen.Month) + assertThat(CalendarView.Agenda.screen).isEqualTo(ViewScreen.Agenda) + } +} From f9dd7a22b7e94c295190ad840b8d9fff29c54a48 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:28:10 +0200 Subject: [PATCH 14/17] Hold the top bar and FAB still across view switches (#184) --- .../calendula/ui/agenda/AgendaScreen.kt | 50 ++++++++----- .../calendula/ui/common/CalendarFabColumn.kt | 3 +- .../ui/common/CalendarTitleButton.kt | 1 + .../calendula/ui/common/TodayAction.kt | 4 +- .../calendula/ui/common/ViewMorph.kt | 56 ++++++++++++++ .../calendula/ui/common/ViewSwitcherPill.kt | 4 +- .../calendula/ui/month/MonthScreen.kt | 74 +++++++++++-------- .../calendula/ui/week/WeekScreen.kt | 60 ++++++++------- 8 files changed, 173 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 2386ce0..f42499c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -50,6 +50,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.morphChrome +import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome +import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -115,6 +118,9 @@ fun AgendaScreen( val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val drawerState = rememberDrawerState(DrawerValue.Closed) + // The bar and FAB hold still through a view switch, but not over an open drawer. + val chromeMorph = drawerState.currentValue == DrawerValue.Closed && + drawerState.targetValue == DrawerValue.Closed val scope = rememberCoroutineScope() var showRangePicker by remember { mutableStateOf(false) } @@ -173,24 +179,28 @@ fun AgendaScreen( Scaffold( modifier = modifier, topBar = { - AgendaTopBar( - selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) + ViewMorphChrome(chromeMorph) { + AgendaTopBar( + selectedView = selectedView, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, + quickSwitchViews = quickSwitchViews, + onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, + showTodayButton = todayInToolbar, + onToday = jumpToToday, + ) + } }, floatingActionButton = { - CalendarFabColumn( - todayVisible = (!isOnToday || (scrolledAway && successState != null)) && - !todayInToolbar, - todayText = stringResource(R.string.agenda_today_action), - onToday = jumpToToday, - onCreate = { onCreateEvent(anchor, null) }, - ) + ViewMorphChrome(chromeMorph) { + CalendarFabColumn( + todayVisible = (!isOnToday || (scrolledAway && successState != null)) && + !todayInToolbar, + todayText = stringResource(R.string.agenda_today_action), + onToday = jumpToToday, + onCreate = { onCreateEvent(anchor, null) }, + ) + } }, ) { innerPadding -> Column( @@ -510,11 +520,13 @@ private fun AgendaTopBar( style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = AppBarSpacing.TitleInset), + modifier = Modifier + .morphChrome(ChromeSlot.Title) + .padding(start = AppBarSpacing.TitleInset), ) }, navigationIcon = { - IconButton(onClick = onOpenDrawer) { + IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) { Icon( imageVector = Icons.Default.Menu, contentDescription = stringResource(R.string.month_open_menu), @@ -523,7 +535,7 @@ private fun AgendaTopBar( }, actions = { TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch) { + IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) { Icon( imageVector = Icons.Default.Search, contentDescription = stringResource(R.string.search_action), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt index bc68617..44ee7c3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -48,7 +49,7 @@ fun CalendarFabColumn( text = { Text(todayText) }, ) } - FloatingActionButton(onClick = onCreate) { + FloatingActionButton(onClick = onCreate, modifier = Modifier.morphChrome(ChromeSlot.Create)) { Icon( imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.event_edit_new_title), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt index bdd9e9a..73a4ae9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt @@ -56,6 +56,7 @@ fun CalendarTitleButton( Row( modifier = modifier + .morphChrome(ChromeSlot.Title) .heightIn(min = 48.dp) .clip(MaterialTheme.shapes.large) .clickable( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt index 583ee4a..903447f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt @@ -59,7 +59,9 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) { val description = stringResource(R.string.today_jump_action) IconButton( onClick = onToday, - modifier = Modifier.semantics { contentDescription = description }, + modifier = Modifier + .morphChrome(ChromeSlot.Today) + .semantics { contentDescription = description }, ) { val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() } Box( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt index ebad7c3..10c7aab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -20,6 +20,8 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.Alignment import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Path @@ -200,6 +202,60 @@ fun TimelineGrounds( } } +/** + * The pieces of the top bar and the FAB every view has (#184). Those drawn the + * same in each view hold still through a switch instead of fading out and back + * in; the title and the view pill, which differ, cross-fade in place. + */ +enum class ChromeSlot(val identical: Boolean) { + Menu(identical = true), + Search(identical = true), + Today(identical = true), + Create(identical = true), + Title(identical = false), + Pill(identical = false), +} + +/** + * Provides the view morph to a screen's top bar or FAB only while [enabled]: + * with its drawer open or opening, what travels would be drawn over the drawer. + */ +@Composable +fun ViewMorphChrome(enabled: Boolean, content: @Composable () -> Unit) = ViewMorphPage(enabled, content) + +/** Tags a piece of the top bar or the FAB; see [ChromeSlot]. */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun Modifier.morphChrome(slot: ChromeSlot): Modifier { + val morph = LocalViewMorph.current ?: return this + return with(morph.shared) { + val state = rememberSharedContentState(slot) + if (slot.identical) { + this@morphChrome.sharedElement( + sharedContentState = state, + animatedVisibilityScope = morph.visibility, + boundsTransform = morph.boundsTransform, + zIndexInOverlay = CHROME_Z_INDEX, + ) + } else { + this@morphChrome.sharedBounds( + sharedContentState = state, + animatedVisibilityScope = morph.visibility, + enter = fadeIn(morph.fadeSpec), + exit = fadeOut(morph.fadeSpec), + boundsTransform = morph.boundsTransform, + // Each side keeps its own size: a title squeezed to the other's + // width would ellipsise and unfold on the way. + resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(ContentScale.None, Alignment.CenterStart), + zIndexInOverlay = CHROME_Z_INDEX, + ) + } + } +} + +/** Above the events and dates, which pass under the top bar on their way. */ +private const val CHROME_Z_INDEX = 2f + /** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */ @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable 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 3ca60cd..9d8d0d0 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 @@ -40,7 +40,9 @@ fun ViewSwitcherPill( FilledTonalButton( onClick = onCycle, shape = MaterialTheme.shapes.large, - modifier = modifier.padding(end = trailingInset), + modifier = modifier + .padding(end = trailingInset) + .morphChrome(ChromeSlot.Pill), ) { // The label cross-fades and the pill eases to its width, so cycling // views turns it over rather than snapping it (#184). 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 d12529c..b8ac85c 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 @@ -97,6 +97,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.morphChrome +import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome +import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -237,6 +240,9 @@ fun MonthScreen( } val drawerState = rememberDrawerState(DrawerValue.Closed) + // The bar and FAB hold still through a view switch, but not over an open drawer. + val chromeMorph = drawerState.currentValue == DrawerValue.Closed && + drawerState.targetValue == DrawerValue.Closed val scope = rememberCoroutineScope() val scrolling = viewStyle.isScrolling @@ -420,38 +426,42 @@ fun MonthScreen( Scaffold( modifier = modifier, topBar = { - MonthTopBar( - title = topBarTitle, - shortTitle = topBarShortTitle, - titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), - selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - onJumpToDate = jumpToDate, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) + ViewMorphChrome(chromeMorph) { + MonthTopBar( + title = topBarTitle, + shortTitle = topBarShortTitle, + titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), + selectedView = selectedView, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, + quickSwitchViews = quickSwitchViews, + onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, + onJumpToDate = jumpToDate, + showTodayButton = todayInToolbar, + onToday = jumpToToday, + ) + } }, floatingActionButton = { - CalendarFabColumn( - todayVisible = !isOnCurrentMonth && !todayInToolbar, - todayText = stringResource(R.string.month_today_action), - onToday = jumpToToday, - onCreate = { - // Split has a selected day; the other styles anchor on - // today when its month is shown, else the 1st. - onCreateEvent( - when { - viewStyle == MonthViewStyle.Split -> selectedDate - isOnCurrentMonth -> today - else -> LocalDate(titleMonth.year, titleMonth.month, 1) - }, - null, - ) - }, - ) + ViewMorphChrome(chromeMorph) { + CalendarFabColumn( + todayVisible = !isOnCurrentMonth && !todayInToolbar, + todayText = stringResource(R.string.month_today_action), + onToday = jumpToToday, + onCreate = { + // Split has a selected day; the other styles anchor on + // today when its month is shown, else the 1st. + onCreateEvent( + when { + viewStyle == MonthViewStyle.Split -> selectedDate + isOnCurrentMonth -> today + else -> LocalDate(titleMonth.year, titleMonth.month, 1) + }, + null, + ) + }, + ) + } }, ) { innerPadding -> // Hoisted above every style's grid: a dragged chip is drawn in an @@ -810,7 +820,7 @@ private fun MonthTopBar( ) }, navigationIcon = { - IconButton(onClick = onOpenDrawer) { + IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) { Icon( imageVector = Icons.Default.Menu, contentDescription = stringResource(R.string.month_open_menu), @@ -819,7 +829,7 @@ private fun MonthTopBar( }, actions = { TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch) { + IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) { Icon( imageVector = Icons.Default.Search, contentDescription = stringResource(R.string.search_action), 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 8b00def..c5ea80a 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 @@ -68,6 +68,9 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.morphChrome +import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome +import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.first @@ -351,6 +354,9 @@ private fun PagedTimelineScreen( } val drawerState = rememberDrawerState(DrawerValue.Closed) + // The bar and FAB hold still through a view switch, but not over an open drawer. + val chromeMorph = drawerState.currentValue == DrawerValue.Closed && + drawerState.targetValue == DrawerValue.Closed val scope = rememberCoroutineScope() // A new grid numbers the pages differently, so it gets a pager of its own, @@ -472,31 +478,35 @@ private fun PagedTimelineScreen( Scaffold( modifier = modifier, topBar = { - WeekTopBar( - kind = titleKind, - weekStart = weekStart, - weekEnd = weekEnd, - currentYear = today.year, - selectedView = view, - onCycleView = { onSelectView(view.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - onJumpToDate = jumpToDate, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) + ViewMorphChrome(chromeMorph) { + WeekTopBar( + kind = titleKind, + weekStart = weekStart, + weekEnd = weekEnd, + currentYear = today.year, + selectedView = view, + onCycleView = { onSelectView(view.next(quickSwitchViews)) }, + quickSwitchViews = quickSwitchViews, + onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, + onJumpToDate = jumpToDate, + showTodayButton = todayInToolbar, + onToday = jumpToToday, + ) + } }, floatingActionButton = { - CalendarFabColumn( - todayVisible = !isOnCurrentWeek && !todayInToolbar, - todayText = stringResource(titleKind.todayText), - onToday = jumpToToday, - onCreate = { - // Anchor on today when it's in view, else the page's first day. - onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) - }, - ) + ViewMorphChrome(chromeMorph) { + CalendarFabColumn( + todayVisible = !isOnCurrentWeek && !todayInToolbar, + todayText = stringResource(titleKind.todayText), + onToday = jumpToToday, + onCreate = { + // Anchor on today when it's in view, else the page's first day. + onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) + }, + ) + } }, ) { innerPadding -> CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { @@ -955,7 +965,7 @@ private fun WeekTopBar( ) }, navigationIcon = { - IconButton(onClick = onOpenDrawer) { + IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) { Icon( imageVector = Icons.Default.Menu, contentDescription = stringResource(R.string.month_open_menu), @@ -964,7 +974,7 @@ private fun WeekTopBar( }, actions = { TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch) { + IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) { Icon( imageVector = Icons.Default.Search, contentDescription = stringResource(R.string.search_action), From 254fafe42bb923b1cfdadb7e646dfd025801720b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:31:59 +0200 Subject: [PATCH 15/17] Document how the views switch (#184) --- .../calendula/ui/CalendarHost.kt | 7 ++-- .../calendula/ui/common/ViewFocus.kt | 2 +- docs/ARCHITECTURE.md | 42 +++++++++++++++---- 3 files changed, 39 insertions(+), 12 deletions(-) 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 343145e..e3e38c4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -71,8 +71,9 @@ import kotlin.time.Clock /** * Holds the top-level view back stack (spec M1) and swaps between the calendar - * screens. Each screen owns its own ViewModel and date anchor; the view-switcher - * pill in their top bars writes back here via [onSelectView]. + * screens. Each screen owns its own ViewModel; they all open on the one focused + * date held here ([ViewFocus]), and the view-switcher pill in their top bars + * writes back here via [onSelectView]. * * The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view. * A lateral switch (pill / drawer) builds a visit history so back retraces it @@ -382,7 +383,7 @@ fun CalendarHost( } Box(modifier = modifier.fillMaxSize()) { - // Switching between the peer views (month/week/day/agenda) is lateral + // Switching between the month, timeline and agenda screens is lateral // navigation, so it fades through rather than sliding — paging *within* a // view keeps the directional slide. What both views show morphs across // (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt index 74ac346..2393e48 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt @@ -37,5 +37,5 @@ val LocalViewFocus = staticCompositionLocalOf { null } @Composable fun EnterOnFocus(key: T, enter: (LocalDate) -> Unit) { val focus = LocalViewFocus.current - remember(key) { focus?.let { enter(it.date) } } + remember(key) { focus?.date?.also(enter) } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6431da6..f31a061 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,7 +27,7 @@ the package list (recurring writes, save conflicts, reminder delivery). ```mermaid flowchart TD subgraph UI ["ui/ — Compose screens + ViewModels"] - Screens["Month / Week / Day\nDetail / Edit / Settings\nOnboarding wizard"] + Screens["Month / Timeline / Agenda\nDetail / Edit / Settings\nOnboarding wizard"] end subgraph Data ["data/"] Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"] @@ -79,15 +79,41 @@ flowchart TD There is no navigation library. `MainActivity` hosts `RootScreen`, which gates on the first-launch wizard (`ui/onboarding/`), then shows -`CalendarHost`. `CalendarHost` holds the active view (month/week/day) -plus overlay state for detail, edit, and settings — full-screen overlays +`CalendarHost`. `CalendarHost` holds the active view (month, day/multi-day/week, +agenda) plus overlay state for detail, edit, and settings — full-screen overlays driven by `AnimatedVisibility` with a *held-key* pattern: the last shown key stays alive through the slide-out so content never flashes empty. -The views themselves switch inside a `SharedTransitionLayout`: an event shown -in both morphs from its old bounds to its new ones, keyed per occurrence *and* -per day it is drawn from (`ViewMorphKey`), and only a pager's settled page -carries the tags. Overlays leave through floret-kit's `predictiveBackExit`, so -a committed back gesture finishes from its scaled preview. +Overlays leave through floret-kit's `predictiveBackExit`, so a committed back +gesture finishes from its scaled preview. + +### Switching views (#184) + +- **One focused date.** `CalendarHost` owns a `ViewFocus` (`LocalViewFocus`) + that every view opens on and carries along. A view *reads* it once, on + entry, synchronously (`EnterOnFocus` before it reads its own position, so + its first frame is already on that date), and *writes* it only when the user + moves — a pager settling (`focusOnPage` keeps the date's place in the page), + a jump, Today, a tapped day, the agenda's top row. Writing on entry would + drift the date on every round trip. +- **Three screens, not five.** The host's `AnimatedContent` is keyed on + `ViewScreen` (month, timeline, agenda), not on the view: day, multi-day and + week are one `TimelineScreen` with a `TimelineKind` each and a view model + each. Keying the three on one `contentKey` instead would still replay the + enter transition on every switch among them. +- **Timeline ↔ timeline resizes in place.** `ColumnGeometry` places every + column (header cell, all-day bar, day column) in the layout pass; a switch + swaps the pager for a frame over both pages' days, each column lerped + between its slot on the one page and the other — a day the page doesn't + show carries on at its pitch, off screen, which is what slides it in or + out. Blocks sit in their column as shares of its width (`LaneColumn`), so + nothing recomposes per frame. The frame starts and ends exactly as the two + pages lay out, so the pagers either side hand over without a jump. +- **Across screens, what both show morphs.** Inside a `SharedTransitionLayout`, + an event shown in both views travels from its old bounds to its new ones, + keyed per occurrence *and* per day it is drawn from (`ViewMorphKey`); only a + pager's settled page carries the tags. The top bar's shared pieces and the + create FAB are tagged too (`morphChrome`), so they hold still while the + rest cross-fades — except over an open drawer. A tapped reminder notification routes through `MainActivity` (`singleTop` + `onNewIntent`) as an external detail key that `CalendarHost` consumes exactly like an event tap. From 62ea5ce9d3d46ace01c71cc3973a82a642f2fc0f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 21:12:22 +0200 Subject: [PATCH 16/17] Share one top bar across views and smooth the view switches (#184) --- .../calendula/ui/CalendarHost.kt | 135 +++++-- .../calendula/ui/agenda/AgendaScreen.kt | 261 ++++--------- .../calendula/ui/common/CalendarChrome.kt | 161 ++++++++ .../calendula/ui/common/CalendarFabColumn.kt | 3 +- .../ui/common/CalendarTitleButton.kt | 33 +- .../ui/common/CalendarTransitions.kt | 17 + .../calendula/ui/common/TimelineScroll.kt | 26 +- .../calendula/ui/common/TodayAction.kt | 3 +- .../calendula/ui/common/ViewMorph.kt | 153 ++------ .../calendula/ui/common/ViewSwitcherPill.kt | 5 +- .../calendula/ui/month/MonthScreen.kt | 309 +++++---------- .../calendula/ui/week/TimelineMorph.kt | 5 + .../calendula/ui/week/WeekScreen.kt | 358 ++++++------------ .../calendula/ui/week/WeekViewModel.kt | 17 + docs/ARCHITECTURE.md | 7 +- 15 files changed, 677 insertions(+), 816 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt 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 e3e38c4..c5b2f59 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -9,16 +9,22 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import de.jeanlucmakiola.calendula.ui.common.screen import de.jeanlucmakiola.calendula.ui.common.ViewScreen @@ -34,6 +40,18 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip +import de.jeanlucmakiola.calendula.ui.common.StayingPut +import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer +import de.jeanlucmakiola.calendula.ui.common.CalendarFab +import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar +import de.jeanlucmakiola.calendula.ui.common.ChromeHost +import de.jeanlucmakiola.calendula.ui.common.ChromeSlot +import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot +import de.jeanlucmakiola.calendula.ui.common.next +import de.jeanlucmakiola.calendula.ui.week.DayViewModel +import de.jeanlucmakiola.calendula.ui.week.MultiDayViewModel +import de.jeanlucmakiola.calendula.ui.week.TimelineViewModels +import de.jeanlucmakiola.calendula.ui.week.WeekViewModel import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.identity.rememberReduceMotion @@ -64,6 +82,8 @@ 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.TimelineScreen +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -130,14 +150,33 @@ fun CalendarHost( } val timelineView = if (view.screen == ViewScreen.Timeline) view else heldTimeline SideEffect { heldTimeline = timelineView } - val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) } // The date every view opens on and carries along (#184); tapping a day // anywhere focuses it and opens the day view there. val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } + + // Into the timeline from another screen, its page is loaded first, briefly, + // so its events are there to morph into rather than arriving mid-way. + val timelineModels = TimelineViewModels( + hiltViewModel(), + hiltViewModel(), + hiltViewModel(), + ) + val switchScope = rememberCoroutineScope() + val switchTo: (CalendarView, (List) -> List) -> Unit = { target, move -> + if (target.screen == ViewScreen.Timeline && viewStack.last().screen != ViewScreen.Timeline) { + switchScope.launch { + withTimeoutOrNull(VIEW_DATA_WAIT_MS) { timelineModels[target].prepare(focus.date) } + viewStack = move(viewStack) + } + } else { + viewStack = move(viewStack) + } + } + val onSelectView: (CalendarView) -> Unit = { target -> switchTo(target) { it.selectView(target) } } val onOpenDay: (LocalDate) -> Unit = { date -> focus.date = date - viewStack = viewStack.drillToDay() + switchTo(CalendarView.Day) { it.drillToDay() } } // The event-detail screen (S4) is a full-screen destination hoisted here so @@ -392,6 +431,9 @@ fun CalendarHost( val morphBounds = rememberViewMorphBoundsTransform() val morphFade = rememberCalendarFadeSpec() val morphClip = remember { BelowTopBarClip() } + val stayingPut = remember { StayingPut() } + val chromeHost = remember { ChromeHost() } + val drawerState = rememberDrawerState(DrawerValue.Closed) val density = LocalDensity.current val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() } @@ -404,10 +446,60 @@ fun CalendarHost( // (#239); nothing else in the calendar surfaces navigates here. LocalManageCalendars provides { showCalendars = true }, ) { + // One drawer, top bar and FAB for every screen (#184): a switch changes + // what they say, from what the screen on show publishes, rather than + // swapping them for another screen's. + ModalNavigationDrawer( + drawerState = drawerState, + // Open only via the menu button where edge-swipe would fight paging; + // the agenda has no paging to fight. + gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda, + drawerContent = { + CalendarDrawer( + currentView = view, + currentDate = chromeHost.chrome?.drawerDate ?: focus.date, + drawerState = drawerState, + viewOrder = drawerViewOrder, + onSelectView = { target -> + // Once the drawer is out of the way, so the switch is seen whole. + switchScope.launch { + try { + drawerState.close() + } finally { + onSelectView(target) + } + } + }, + onJumpToDate = { target -> + chromeHost.actions?.onJumpToDate?.invoke(target) + switchScope.launch { drawerState.close() } + }, + onSettings = { + onOpenSettings() + switchScope.launch { drawerState.close() } + }, + ) + }, + ) { + Scaffold( + topBar = { + CalendarTopBar( + host = chromeHost, + selectedView = view, + quickSwitchViews = quickSwitchViews, + onCycleView = { onSelectView(view.next(quickSwitchViews)) }, + onOpenDrawer = { switchScope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, + showTodayButton = todayInToolbar, + ) + }, + floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) }, + ) { innerPadding -> SharedTransitionLayout( Modifier + .padding(innerPadding) .fillMaxSize() - .onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y }, +.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y }, ) { // Keyed on the screen, not the view: the day, multi-day and week views // are one timeline that resizes between them itself (#184), so switching @@ -419,52 +511,38 @@ fun CalendarHost( ) { screen -> val sharedScope = this@SharedTransitionLayout val visibilityScope = this@AnimatedContent - val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip) { - if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip) + val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) { + if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) } - CompositionLocalProvider(LocalViewMorph provides viewMorph) { + CompositionLocalProvider( + LocalViewMorph provides viewMorph, + LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen), + ) { when (screen) { ViewScreen.Timeline -> TimelineScreen( view = timelineView, - onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) ViewScreen.Month -> MonthScreen( - selectedView = CalendarView.Month, - onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) ViewScreen.Agenda -> AgendaScreen( - selectedView = CalendarView.Agenda, - onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) } } } } } + } + } // Scope prompt + confirmation/undo snackbar for a dropped event, declared // right after the calendar views so later overlays cover it. @@ -670,3 +748,6 @@ private val viewStackSaver = listSaver, Int>( private fun localToday(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + +/** How long a switch into the timeline waits for its page before going anyway. */ +private const val VIEW_DATA_WAIT_MS = 200L diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index f42499c..209867f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -1,8 +1,14 @@ package de.jeanlucmakiola.calendula.ui.agenda +import de.jeanlucmakiola.calendula.ui.common.PublishChrome +import de.jeanlucmakiola.calendula.ui.common.ScreenActions +import de.jeanlucmakiola.calendula.ui.common.ScreenBody +import de.jeanlucmakiola.calendula.ui.common.ScreenChrome import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus import kotlinx.coroutines.flow.drop +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.rememberUpdatedState import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey @@ -25,37 +31,22 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Coffee -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DrawerValue import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import de.jeanlucmakiola.calendula.ui.common.morphChrome -import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome -import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -68,14 +59,9 @@ import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.floret.identity.animateItemMotion -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer -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.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.floret.locale.currentLocale @@ -91,16 +77,10 @@ import kotlin.time.Instant @OptIn(ExperimentalMaterial3Api::class) @Composable fun AgendaScreen( - 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: AgendaViewModel = hiltViewModel(), ) { @@ -117,10 +97,6 @@ fun AgendaScreen( val showToday by viewModel.showToday.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() - val drawerState = rememberDrawerState(DrawerValue.Closed) - // The bar and FAB hold still through a view switch, but not over an open drawer. - val chromeMorph = drawerState.currentValue == DrawerValue.Closed && - drawerState.targetValue == DrawerValue.Closed val scope = rememberCoroutineScope() var showRangePicker by remember { mutableStateOf(false) } @@ -152,111 +128,78 @@ fun AgendaScreen( (state as? AgendaUiState.Success)?.today?.let { focus?.date = it } } - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = anchor, - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - focus?.date = target - viewModel.goToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, - ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - ViewMorphChrome(chromeMorph) { - AgendaTopBar( - selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) - } + PublishChrome( + ScreenChrome( + title = stringResource(R.string.view_agenda), + titleDate = null, + drawerDate = anchor, + awayFromToday = !isOnToday || (scrolledAway && successState != null), + todayText = stringResource(R.string.agenda_today_action), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = { target -> + focus?.date = target + viewModel.goToDate(target) }, - floatingActionButton = { - ViewMorphChrome(chromeMorph) { - CalendarFabColumn( - todayVisible = (!isOnToday || (scrolledAway && successState != null)) && - !todayInToolbar, - todayText = stringResource(R.string.agenda_today_action), - onToday = jumpToToday, - onCreate = { onCreateEvent(anchor, null) }, - ) + onCreate = { onCreateEvent(anchor, null) }, + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // One bar at the top: the "showing …" header on the left and the + // session range switcher on the right (one settings toggle). + successState?.takeIf { it.showRangeBar }?.let { s -> + // end lines the selector up with whatever ends the top bar: + // the view switcher's background, or — once #150 hides it — + // the search icon's glyph. + val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) { + AppBarSpacing.Inset + } else { + AppBarSpacing.IconTrailingInset } - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - // One bar at the top: the "showing …" header on the left and the - // session range switcher on the right (one settings toggle). - successState?.takeIf { it.showRangeBar }?.let { s -> - // end lines the selector up with whatever ends the top bar: - // the view switcher's background, or — once #150 hides it — - // the search icon's glyph. - val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) { - AppBarSpacing.Inset - } else { - AppBarSpacing.IconTrailingInset - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding( - start = RANGE_BAR_TEXT_INSET, - end = selectorEnd, - top = 8.dp, - bottom = 8.dp, - ), - ) { - AgendaRangeBanner( - range = s.range, - start = s.anchor, - end = s.rangeEnd, - modifier = Modifier.weight(1f), - ) - AgendaRangePill( - range = s.range, - isOverride = s.rangeIsOverride, - onClick = { showRangePicker = true }, - ) - } - } - AgendaContent( - state = state, - pastDisplay = pastDisplay, - showToday = showToday, - listState = listState, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onTopDay = { focus?.date = it }, + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) + .fillMaxWidth() + .padding( + start = RANGE_BAR_TEXT_INSET, + end = selectorEnd, + top = 8.dp, + bottom = 8.dp, + ), + ) { + AgendaRangeBanner( + range = s.range, + start = s.anchor, + end = s.rangeEnd, + modifier = Modifier.weight(1f), + ) + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + ) + } } + AgendaContent( + state = state, + pastDisplay = pastDisplay, + showToday = showToday, + listState = listState, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onTopDay = { focus?.date = it }, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) } } @@ -500,59 +443,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AgendaTopBar( - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - TopAppBar( - title = { - // A plain label rather than a CalendarTitleButton, so it takes that - // one's inset and one-line clamp itself. - Text( - text = stringResource(R.string.view_agenda), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .morphChrome(ChromeSlot.Title) - .padding(start = AppBarSpacing.TitleInset), - ) - }, - navigationIcon = { - IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(R.string.month_open_menu), - ) - } - }, - actions = { - TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search_action), - ) - } - ViewSwitcherPill( - current = selectedView, - cycle = quickSwitchViews, - onCycle = onCycleView, - ) - }, - // Deliberately flat: M3 lifts the bar to mark content scrolling under - // it, but here the bar meets the header on the same surface and the - // tint is what makes that seam look like a separate block (#186). - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt new file mode 100644 index 0000000..173d29d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt @@ -0,0 +1,161 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.calendula.R +import kotlinx.datetime.LocalDate + +/** + * What a calendar screen shows in the top bar, the FAB and the drawer, which the + * host draws once for all of them (#184): switching screens changes what they + * say rather than swapping one bar for another. + * + * @param titleDate the date the title's picker opens on, or null for a title + * that is only a label. + * @param drawerDate the date the drawer's picker opens on. + * @param awayFromToday whether "today" is worth offering. + */ +@Immutable +data class ScreenChrome( + val title: String, + val shortTitle: String = title, + val titleDate: LocalDate?, + val drawerDate: LocalDate, + val awayFromToday: Boolean, + val todayText: String, +) + +/** What the chrome does for the screen on show; read when used, so not observed. */ +class ScreenActions( + val onToday: () -> Unit, + val onJumpToDate: (LocalDate) -> Unit, + val onCreate: () -> Unit, +) + +/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */ +@Stable +class ChromeHost { + var chrome by mutableStateOf(null) + private set + + var actions: ScreenActions? = null + private set + + internal fun publish(chrome: ScreenChrome, actions: ScreenActions) { + this.chrome = chrome + this.actions = actions + } +} + +/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */ +class ChromeSlot(val host: ChromeHost, val current: Boolean) + +val LocalChromeSlot = staticCompositionLocalOf { null } + +/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */ +@Composable +fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) { + val slot = LocalChromeSlot.current ?: return + if (slot.current) SideEffect { slot.host.publish(chrome, actions) } +} + +/** The top bar every calendar screen shares. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarTopBar( + host: ChromeHost, + selectedView: CalendarView, + quickSwitchViews: List, + onCycleView: () -> Unit, + onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, + showTodayButton: Boolean, +) { + val chrome = host.chrome + TopAppBar( + title = { + if (chrome != null) { + CalendarTitleButton( + title = chrome.title, + currentDate = chrome.titleDate, + onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) }, + shortTitle = chrome.shortTitle, + ) + } + }, + navigationIcon = { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(R.string.month_open_menu), + ) + } + }, + actions = { + TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() }) + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } + ViewSwitcherPill( + current = selectedView, + cycle = quickSwitchViews, + onCycle = onCycleView, + ) + }, + // Deliberately flat: M3 lifts the bar to mark content scrolling under + // it, but here the bar meets the header on the same surface and the + // tint is what makes that seam look like a separate block (#186). + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) +} + +/** The FAB column every calendar screen shares. */ +@Composable +fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) { + val chrome = host.chrome ?: return + CalendarFabColumn( + todayVisible = chrome.awayFromToday && !todayInToolbar, + todayText = chrome.todayText, + onToday = { host.actions?.onToday?.invoke() }, + onCreate = { host.actions?.onCreate?.invoke() }, + ) +} + +/** + * A calendar screen's body under the shared chrome, on the ground the screen's + * own Scaffold used to give it; the host's Scaffold has already made room for + * the bars, so [content] gets no padding of its own. + */ +@Composable +fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) { + Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + content(PaddingValues()) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt index 44ee7c3..bc68617 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -15,7 +15,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -49,7 +48,7 @@ fun CalendarFabColumn( text = { Text(todayText) }, ) } - FloatingActionButton(onClick = onCreate, modifier = Modifier.morphChrome(ChromeSlot.Create)) { + FloatingActionButton(onClick = onCreate) { Icon( imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.event_edit_new_title), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt index 73a4ae9..3f4f890 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt @@ -1,6 +1,9 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -15,12 +18,12 @@ import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -38,7 +41,8 @@ import kotlinx.datetime.LocalDate * drawer uses and reporting the chosen day through [onJumpToDate]. * * [currentDate] seeds the picker with whatever the bar is currently naming (the - * visible day, week start or month anchor). + * visible day, week start or month anchor); null makes the title a plain label, + * as the agenda's is, and the caret folds away. * * [shortTitle] replaces [title] when the full one does not fit the width the app * bar hands the title slot (#165). Either way the line clamps to one and @@ -47,7 +51,7 @@ import kotlinx.datetime.LocalDate @Composable fun CalendarTitleButton( title: String, - currentDate: LocalDate, + currentDate: LocalDate?, onJumpToDate: (LocalDate) -> Unit, modifier: Modifier = Modifier, shortTitle: String = title, @@ -56,10 +60,10 @@ fun CalendarTitleButton( Row( modifier = modifier - .morphChrome(ChromeSlot.Title) .heightIn(min = 48.dp) .clip(MaterialTheme.shapes.large) .clickable( + enabled = currentDate != null, onClickLabel = stringResource(R.string.drawer_jump_to_date), role = Role.Button, ) { showDatePicker = true } @@ -86,7 +90,6 @@ fun CalendarTitleButton( (fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false)) }, contentAlignment = Alignment.CenterStart, - label = "calendar-title", ) { text -> Text( text = text, @@ -96,14 +99,20 @@ fun CalendarTitleButton( ) } } - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + AnimatedVisibility( + visible = currentDate != null, + enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(), + exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(), + ) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - if (showDatePicker) { + if (showDatePicker && currentDate != null) { CalendarDatePickerDialog( initial = currentDate, onConfirm = { 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 948d1f2..9c5c52b 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,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.spring import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -52,3 +53,19 @@ fun rememberCalendarPageSpec(): FiniteAnimationSpec = @Composable fun rememberCalendarFadeSpec(): FiniteAnimationSpec = MaterialTheme.motionScheme.defaultEffectsSpec() + +/** + * A switch between views (#184): the default spatial spring, a little softer, as + * the whole screen reshapes rather than a page sliding by; softer still when + * [long], for a change the eye has further to follow. + */ +@Composable +fun rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec = + spring( + dampingRatio = VIEW_SWITCH_DAMPING, + stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS, + ) + +private const val VIEW_SWITCH_DAMPING = 0.9f +private const val VIEW_SWITCH_STIFFNESS = 450f +private const val LONG_VIEW_SWITCH_STIFFNESS = 340f diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt index 3694757..07af21c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt @@ -2,9 +2,13 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.foundation.ScrollState import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf @@ -24,19 +28,34 @@ class TimelineScrollMemory { val LocalTimelineScroll = staticCompositionLocalOf { null } +/** A timeline's scroll, and where it opens before it can scroll there. */ +@Stable +class TimelineScroll(val state: ScrollState, noonPending: Boolean) { + /** Whether it is still to be put on noon, which it only can be once laid out. */ + var noonPending by mutableStateOf(noonPending) + internal set + + /** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */ + fun top(viewport: Float, content: Float): Float = + if (noonPending) ((content - viewport) / 2).coerceAtLeast(0f) else state.value.toFloat() +} + +val LocalOpeningTimeline = staticCompositionLocalOf { null } + /** * The scroll state of a timeline whose scrolling area starts [timelineTop] below * its view's content top. Opens where the last timeline left off, or on noon the * first time. */ @Composable -fun rememberTimelineScrollState(timelineTop: Dp): ScrollState { +fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll { val memory = LocalTimelineScroll.current val topPx = with(LocalDensity.current) { timelineTop.toPx() } val remembered = memory?.midnightY val scrollState = rememberScrollState( initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0, ) + val scroll = remember(scrollState) { TimelineScroll(scrollState, noonPending = remembered == null) } val currentTop by rememberUpdatedState(topPx) LaunchedEffect(scrollState) { if (remembered != null) return@LaunchedEffect @@ -44,11 +63,12 @@ fun rememberTimelineScrollState(timelineTop: Dp): ScrollState { // Half the scroll range *is* noon: the content spans a full 24 hours, so // centring the range centres midday at whatever hour height is in force. scrollState.scrollTo(scrollState.maxValue / 2) + scroll.noonPending = false } if (memory != null) { LaunchedEffect(scrollState, memory) { snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it } } } - return scrollState + return scroll } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt index 903447f..d1b27d4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt @@ -7,9 +7,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text +import androidx.compose.runtime.getValue import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,7 +60,6 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) { IconButton( onClick = onToday, modifier = Modifier - .morphChrome(ChromeSlot.Today) .semantics { contentDescription = description }, ) { val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt index 10c7aab..1faa9a8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -7,27 +7,16 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.Alignment -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Path -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.datetime.LocalDate @@ -41,16 +30,12 @@ import kotlinx.datetime.LocalDate * multi-day event is drawn once per day, per week row or per timeline column, * and only one element per key may take part. The segment that starts on the * same day in both views is the one that travels; the rest fade where they are. - * - * A day's ground — its month cell, its timeline column — travels too, so the - * events never arrive ahead of what they sit on. */ sealed interface ViewMorphKey { data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey data class Day(val date: LocalDate) : ViewMorphKey - data class Ground(val date: LocalDate) : ViewMorphKey companion object { /** [event]'s occurrence as drawn from [day]. */ @@ -66,9 +51,30 @@ class ViewMorphScope( val visibility: AnimatedVisibilityScope, val boundsTransform: BoundsTransform, val fadeSpec: FiniteAnimationSpec, - val clip: SharedTransitionScope.OverlayClip, + val clip: BelowTopBarClip, + val stayingPut: StayingPut, ) +/** + * The events a view has but keeps out of the switch, being scrolled out of + * sight: the other view leaves them untagged too, or its half would travel to + * nowhere and snap into place at the end. + */ +class StayingPut { + val keys = mutableStateSetOf() +} + +/** Marks [key] as staying put while [stays], for as long as it is composed. */ +@Composable +fun StayPut(key: ViewMorphKey.Event, stays: Boolean) { + val stayingPut = LocalViewMorph.current?.stayingPut ?: return + if (!stays) return + DisposableEffect(stayingPut, key) { + stayingPut.keys += key + onDispose { stayingPut.keys -= key } + } +} + /** * Keeps what travels below the top bar, which every view has at the same height: * the overlay draws above the whole screen, so an event scrolled under the bar @@ -118,6 +124,7 @@ fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) { @Composable fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier { val morph = LocalViewMorph.current ?: return this + if (key in morph.stayingPut.keys) return this return with(morph.shared) { this@morphEvent.sharedBounds( sharedContentState = rememberSharedContentState(key), @@ -145,121 +152,15 @@ fun Modifier.morphDay(date: LocalDate): Modifier { exit = fadeOut(morph.fadeSpec), boundsTransform = morph.boundsTransform, clipInOverlayDuringTransition = morph.clip, - // Over the grounds, which a month cell's number sits inside. zIndexInOverlay = 1f, ) } } -/** Tags a day's ground, under the events and numbers that travel with it. */ +/** The view switch's travel, on the same spring as the timeline's columns resizing. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable -fun Modifier.morphGround(date: LocalDate): Modifier { - val morph = LocalViewMorph.current ?: return this - return with(morph.shared) { - this@morphGround.sharedBounds( - sharedContentState = rememberSharedContentState(ViewMorphKey.Ground(date)), - animatedVisibilityScope = morph.visibility, - enter = fadeIn(morph.fadeSpec), - exit = fadeOut(morph.fadeSpec), - boundsTransform = morph.boundsTransform, - clipInOverlayDuringTransition = morph.clip, - resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, - ) - } -} - -/** - * The on-screen window of each day's timeline column, painted like the column - * behind the scrolling one: a whole 24-hour column can't travel, its window can. - * Nothing is drawn while there is no morph to take part in. - */ -@Composable -fun TimelineGrounds( - days: List, - scrollState: ScrollState, - hourHeight: Dp, - columnGap: Dp, - modifier: Modifier = Modifier, -) { - if (LocalViewMorph.current == null) return - val showHourGrid = LocalShowHourGrid.current - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } - val cell = MaterialTheme.colorScheme.surfaceContainer - val ground = if (showHourGrid) MaterialTheme.colorScheme.surface else cell - Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(columnGap)) { - days.forEach { day -> - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .morphGround(day) - .clipToBounds() - .background(ground) - .hourGridCells(showHourGrid, hourPx, cell) { scrollState.value.toFloat() }, - ) - } - } -} - -/** - * The pieces of the top bar and the FAB every view has (#184). Those drawn the - * same in each view hold still through a switch instead of fading out and back - * in; the title and the view pill, which differ, cross-fade in place. - */ -enum class ChromeSlot(val identical: Boolean) { - Menu(identical = true), - Search(identical = true), - Today(identical = true), - Create(identical = true), - Title(identical = false), - Pill(identical = false), -} - -/** - * Provides the view morph to a screen's top bar or FAB only while [enabled]: - * with its drawer open or opening, what travels would be drawn over the drawer. - */ -@Composable -fun ViewMorphChrome(enabled: Boolean, content: @Composable () -> Unit) = ViewMorphPage(enabled, content) - -/** Tags a piece of the top bar or the FAB; see [ChromeSlot]. */ -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun Modifier.morphChrome(slot: ChromeSlot): Modifier { - val morph = LocalViewMorph.current ?: return this - return with(morph.shared) { - val state = rememberSharedContentState(slot) - if (slot.identical) { - this@morphChrome.sharedElement( - sharedContentState = state, - animatedVisibilityScope = morph.visibility, - boundsTransform = morph.boundsTransform, - zIndexInOverlay = CHROME_Z_INDEX, - ) - } else { - this@morphChrome.sharedBounds( - sharedContentState = state, - animatedVisibilityScope = morph.visibility, - enter = fadeIn(morph.fadeSpec), - exit = fadeOut(morph.fadeSpec), - boundsTransform = morph.boundsTransform, - // Each side keeps its own size: a title squeezed to the other's - // width would ellipsise and unfold on the way. - resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(ContentScale.None, Alignment.CenterStart), - zIndexInOverlay = CHROME_Z_INDEX, - ) - } - } -} - -/** Above the events and dates, which pass under the top bar on their way. */ -private const val CHROME_Z_INDEX = 2f - -/** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */ -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable fun rememberViewMorphBoundsTransform(): BoundsTransform { - val spec = MaterialTheme.motionScheme.defaultSpatialSpec() + val spec = rememberViewSwitchSpec() return remember(spec) { BoundsTransform { _, _ -> spec } } } 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 9d8d0d0..b33d28c 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 @@ -12,7 +12,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp /** @@ -41,8 +40,7 @@ fun ViewSwitcherPill( onClick = onCycle, shape = MaterialTheme.shapes.large, modifier = modifier - .padding(end = trailingInset) - .morphChrome(ChromeSlot.Pill), + .padding(end = trailingInset), ) { // The label cross-fades and the pill eases to its width, so cycling // views turns it over rather than snapping it (#184). @@ -52,7 +50,6 @@ fun ViewSwitcherPill( (fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false)) }, contentAlignment = Alignment.Center, - label = "view-pill", ) { label -> Text(label) } 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 b8ac85c..953066c 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 @@ -8,15 +8,19 @@ 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.setValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.rememberUpdatedState +import de.jeanlucmakiola.calendula.ui.common.PublishChrome +import de.jeanlucmakiola.calendula.ui.common.ScreenActions +import de.jeanlucmakiola.calendula.ui.common.ScreenBody +import de.jeanlucmakiola.calendula.ui.common.ScreenChrome import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphEvent -import de.jeanlucmakiola.calendula.ui.common.morphGround import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.pagedHeight @@ -38,7 +42,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -66,21 +69,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -94,12 +86,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import de.jeanlucmakiola.calendula.ui.common.morphChrome -import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome -import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -128,7 +116,6 @@ import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS import de.jeanlucmakiola.calendula.ui.common.EventMoveScope -import de.jeanlucmakiola.calendula.domain.spanFirstDay import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.semantics.customActions @@ -160,14 +147,8 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing -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.CalendarView -import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -178,7 +159,6 @@ import de.jeanlucmakiola.calendula.ui.common.EventChipShape 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.rememberCalendarFadeSpec import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.animateItemMotion @@ -207,16 +187,9 @@ import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun MonthScreen( - 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: MonthViewModel = hiltViewModel(), ) { @@ -239,10 +212,6 @@ fun MonthScreen( derivedStateOf { if (dimCompleted) nowState.value else null } } - val drawerState = rememberDrawerState(DrawerValue.Closed) - // The bar and FAB hold still through a view switch, but not over an open drawer. - val chromeMorph = drawerState.currentValue == DrawerValue.Closed && - drawerState.targetValue == DrawerValue.Closed val scope = rememberCoroutineScope() val scrolling = viewStyle.isScrolling @@ -398,135 +367,96 @@ fun MonthScreen( } } - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the month swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = LocalDate(titleMonth.year, titleMonth.month, 1), - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - jumpToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, - ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - ViewMorphChrome(chromeMorph) { - MonthTopBar( - title = topBarTitle, - shortTitle = topBarShortTitle, - titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), - selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - onJumpToDate = jumpToDate, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) - } + PublishChrome( + ScreenChrome( + title = topBarTitle, + shortTitle = topBarShortTitle, + titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), + drawerDate = LocalDate(titleMonth.year, titleMonth.month, 1), + awayFromToday = !isOnCurrentMonth, + todayText = stringResource(R.string.month_today_action), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = jumpToDate, + onCreate = { + // Split has a selected day; the other styles anchor on today when + // its month is shown, else the 1st. + onCreateEvent( + when { + viewStyle == MonthViewStyle.Split -> selectedDate + isOnCurrentMonth -> today + else -> LocalDate(titleMonth.year, titleMonth.month, 1) + }, + null, + ) }, - floatingActionButton = { - ViewMorphChrome(chromeMorph) { - CalendarFabColumn( - todayVisible = !isOnCurrentMonth && !todayInToolbar, - todayText = stringResource(R.string.month_today_action), - onToday = jumpToToday, - onCreate = { - // Split has a selected day; the other styles anchor on - // today when its month is shown, else the 1st. - onCreateEvent( - when { - viewStyle == MonthViewStyle.Split -> selectedDate - isOnCurrentMonth -> today - else -> LocalDate(titleMonth.year, titleMonth.month, 1) - }, - null, - ) - }, - ) - } - }, - ) { innerPadding -> - // Hoisted above every style's grid: a dragged chip is drawn in an - // overlay, clear of the week row's and the list viewport's clips. - val chipDrag = rememberMonthDragController() - // Undo moves the event back, so the chip travels back too. The - // signal comes from the write, not from the chip that offers it. - OnUndoStarted { chipDrag.beginUndo() } - Box( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + // Hoisted above every style's grid: a dragged chip is drawn in an + // overlay, clear of the week row's and the list viewport's clips. + val chipDrag = rememberMonthDragController() + // Undo moves the event back, so the chip travels back too. The + // signal comes from the write, not from the chip that offers it. + OnUndoStarted { chipDrag.beginUndo() } + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + Column(modifier = Modifier.fillMaxSize()) { + WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) + CompositionLocalProvider( + LocalDimCutoff provides dimCutoff, + LocalMonthDrag provides chipDrag, ) { - Column(modifier = Modifier.fillMaxSize()) { - WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) - CompositionLocalProvider( - LocalDimCutoff provides dimCutoff, - LocalMonthDrag provides chipDrag, - ) { - if (scrolling) { - ContinuousMonthContent( - state = continuousState, - listState = listState, - dense = dense, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) - } else if (viewStyle == MonthViewStyle.Split) { - SplitMonthContent( - state = state, - selected = selectedDate, - pagerState = pagerState, - weekStart = weekStart, - monthState = viewModel::month, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - // 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) }, - ) - } else { - MonthContent( - state = state, - pagerState = pagerState, - weekStart = weekStart, - monthState = viewModel::month, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) - } + if (scrolling) { + ContinuousMonthContent( + state = continuousState, + listState = listState, + dense = dense, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) + } else if (viewStyle == MonthViewStyle.Split) { + SplitMonthContent( + state = state, + selected = selectedDate, + pagerState = pagerState, + weekStart = weekStart, + monthState = viewModel::month, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + // 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) }, + ) + } else { + MonthContent( + state = state, + pagerState = pagerState, + weekStart = weekStart, + monthState = viewModel::month, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) } } - MonthDragOverlay(chipDrag) - } + } + MonthDragOverlay(chipDrag) } } } @@ -795,62 +725,6 @@ private fun ContinuousMonthContent( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MonthTopBar( - title: String, - shortTitle: String, - titleDate: LocalDate, - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - onJumpToDate: (LocalDate) -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - TopAppBar( - title = { - CalendarTitleButton( - title = title, - currentDate = titleDate, - onJumpToDate = onJumpToDate, - shortTitle = shortTitle, - ) - }, - navigationIcon = { - IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(R.string.month_open_menu), - ) - } - }, - actions = { - TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search_action), - ) - } - ViewSwitcherPill( - current = selectedView, - cycle = quickSwitchViews, - onCycle = onCycleView, - ) - }, - // Deliberately flat: M3 lifts the bar to mark content scrolling under - // it, but here the bar meets the header on the same surface and the - // tint is what makes that seam look like a separate block (#186). - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) -} - @Composable internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { val locale = currentLocale() @@ -2206,7 +2080,6 @@ private fun MonthWeekRow( .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) .morphElement(MonthMorphKey.Cell(d)) - .morphGround(d) .background( color = when { inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt index 35c43cf..ae52767 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt @@ -20,8 +20,13 @@ internal class TimelineMorph( ) { val progress = Animatable(0f) + /** Whether the columns change size a lot, from a single day to a week or back, and take longer for it. */ + val isLong: Boolean get() = maxOf(from.count, to.count) > LONG_SWITCH_RATIO * minOf(from.count, to.count) + /** Where each day's events come from: the page it was on, else the page it goes to. */ fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To enum class Side { From, To } } + +private const val LONG_SWITCH_RATIO = 3 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 c5ea80a..14c00d8 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 @@ -8,9 +8,16 @@ 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.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateMapOf +import de.jeanlucmakiola.calendula.ui.common.PublishChrome +import de.jeanlucmakiola.calendula.ui.common.ScreenActions +import de.jeanlucmakiola.calendula.ui.common.ScreenBody +import de.jeanlucmakiola.calendula.ui.common.ScreenChrome import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec +import de.jeanlucmakiola.calendula.ui.common.rememberViewSwitchSpec import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.pagedHeight import kotlinx.coroutines.flow.StateFlow @@ -19,7 +26,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -38,29 +44,17 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.key -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -68,14 +62,11 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier -import de.jeanlucmakiola.calendula.ui.common.morphChrome -import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome -import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph +import de.jeanlucmakiola.calendula.ui.common.StayPut import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.first import androidx.compose.runtime.withFrameNanos -import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.SideEffect import de.jeanlucmakiola.calendula.ui.common.laneSlot @@ -109,13 +100,8 @@ import de.jeanlucmakiola.calendula.ui.common.LocalChipGround import de.jeanlucmakiola.calendula.ui.common.eventPaint import de.jeanlucmakiola.calendula.ui.common.eventSurface import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer -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.CalendarView -import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING @@ -149,16 +135,15 @@ import de.jeanlucmakiola.calendula.ui.common.EventChipShape 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.EnterOnFocus import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec -import de.jeanlucmakiola.calendula.ui.common.TimelineGrounds import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphEvent import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState +import de.jeanlucmakiola.calendula.ui.common.LocalOpeningTimeline import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.floret.identity.rememberReduceMotion @@ -177,7 +162,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.next import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate @@ -228,15 +212,9 @@ internal fun WeekUiState.Success.allDayStripHeight(): Dp { @Composable fun TimelineScreen( view: 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, weekViewModel: WeekViewModel = hiltViewModel(), multiDayViewModel: MultiDayViewModel = hiltViewModel(), @@ -248,15 +226,9 @@ fun TimelineScreen( PagedTimelineScreen( view = view, viewModels = viewModels, - onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, modifier = modifier, ) } @@ -311,15 +283,9 @@ private const val MORPH_DATA_WAIT_MS = 150L private fun PagedTimelineScreen( view: CalendarView, viewModels: TimelineViewModels, - 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, ) { // The view on screen, which trails [view] while the columns resize into it. @@ -333,18 +299,20 @@ private fun PagedTimelineScreen( // Opens on the focused date, then carries it along as the pages move. val focus = LocalViewFocus.current EnterOnFocus(viewModel) { viewModel.jumpTo(it) } - val loadedState by viewModel.state.collectAsStateWithLifecycle() + // Keyed on the view model: collected unkeyed, a switch would read the view + // before's anchor and grid for a frame and open its pager. + val loadedState by key(viewModel) { viewModel.state.collectAsStateWithLifecycle() } // A view not shown before has nothing loaded yet; the one before it stands // in, as its pages load their own days anyway, rather than a skeleton. var lastState by remember { mutableStateOf(WeekUiState.Loading) } val state = if (loadedState is WeekUiState.Loading) lastState else loadedState SideEffect { if (loadedState !is WeekUiState.Loading) lastState = loadedState } - val anchor by viewModel.anchor.collectAsStateWithLifecycle() - val rule by viewModel.rule.collectAsStateWithLifecycle() + val anchor by key(viewModel) { viewModel.anchor.collectAsStateWithLifecycle() } + val rule by key(viewModel) { viewModel.rule.collectAsStateWithLifecycle() } val position = remember(anchor, rule) { positionOf(anchor, rule) } val grid = position.grid val anchorPage = position.page - val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() + val dimCompleted by key(viewModel) { 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 // screen while the setting is off (it stays null regardless of the tick). @@ -353,10 +321,6 @@ private fun PagedTimelineScreen( derivedStateOf { if (dimCompleted) nowState.value else null } } - val drawerState = rememberDrawerState(DrawerValue.Closed) - // The bar and FAB hold still through a view switch, but not over an open drawer. - val chromeMorph = drawerState.currentValue == DrawerValue.Closed && - drawerState.targetValue == DrawerValue.Closed val scope = rememberCoroutineScope() // A new grid numbers the pages differently, so it gets a pager of its own, @@ -366,6 +330,8 @@ private fun PagedTimelineScreen( rememberPagerState(initialPage = anchorPage) { grid.pageCount } } val pageSpec = rememberCalendarPageSpec() + val switchSpec = rememberViewSwitchSpec() + val longSwitchSpec = rememberViewSwitchSpec(long = true) val reduceMotion = rememberReduceMotion() // 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 @@ -415,7 +381,7 @@ private fun PagedTimelineScreen( try { withTimeoutOrNull(MORPH_DATA_WAIT_MS) { toLoaded.first { it !is WeekUiState.Loading } } morph = switch - switch.progress.animateTo(1f, pageSpec) + switch.progress.animateTo(1f, if (switch.isLong) longSwitchSpec else switchSpec) } finally { handoffStrip = (toLoaded.value as? WeekUiState.Success)?.allDayStripHeight() shown = view @@ -450,83 +416,46 @@ private fun PagedTimelineScreen( } val jumpToToday = { jumpToDate(today) } - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the week swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = view, - currentDate = weekStart, - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { target -> - onSelectView(target) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - jumpToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, + val locale = currentLocale() + val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) { + titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to + titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true) + } + PublishChrome( + ScreenChrome( + title = title, + shortTitle = shortTitle, + titleDate = weekStart, + drawerDate = weekStart, + awayFromToday = !isOnCurrentWeek, + todayText = stringResource(titleKind.todayText), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = jumpToDate, + // Anchor on today when it's in view, else the page's first day. + onCreate = { onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) }, + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { + WeekContent( + state = state, + pagerState = pagerState, + grid = grid, + kind = kind, + morph = morph, + handoffStrip = handoffStrip, + span = viewModel::span, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - ViewMorphChrome(chromeMorph) { - WeekTopBar( - kind = titleKind, - weekStart = weekStart, - weekEnd = weekEnd, - currentYear = today.year, - selectedView = view, - onCycleView = { onSelectView(view.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - onJumpToDate = jumpToDate, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) - } - }, - floatingActionButton = { - ViewMorphChrome(chromeMorph) { - CalendarFabColumn( - todayVisible = !isOnCurrentWeek && !todayInToolbar, - todayText = stringResource(titleKind.todayText), - onToday = jumpToToday, - onCreate = { - // Anchor on today when it's in view, else the page's first day. - onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) - }, - ) - } - }, - ) { innerPadding -> - CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { - WeekContent( - state = state, - pagerState = pagerState, - grid = grid, - kind = kind, - morph = morph, - handoffStrip = handoffStrip, - span = viewModel::span, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) - } } } } @@ -552,7 +481,8 @@ private fun WeekContent( // survives week-to-week swipes (e.g. 18:00 stays centred), and carried over // from the timeline view before this one. val stripHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp - val scrollState = rememberTimelineScrollState(weekDayHeaderHeight() + stripHeight + TIMELINE_TOP_GAP) + val timelineScroll = rememberTimelineScrollState(weekDayHeaderHeight() + stripHeight + TIMELINE_TOP_GAP) + val scrollState = timelineScroll.state // Above the pager: a page change mid-drag would strand the floating block // inside the outgoing page. @@ -571,22 +501,37 @@ private fun WeekContent( } 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(), - contentKey = { it::class }, - transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, - label = "week-state", - ) { s -> - when (s) { - WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount) - is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is WeekUiState.Success -> if (morph != null) { - TimelineMorphFrame( - morph = morph, + CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) { + // 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(), + contentKey = { it::class }, + transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, + label = "week-state", + ) { s -> + when (s) { + WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount) + is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) + is WeekUiState.Success -> if (morph != null) { + TimelineMorphFrame( + morph = morph, + today = s.today, + scrollState = scrollState, + dragController = dragController, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } else WeekPager( + pagerState = pagerState, + grid = grid, + kind = kind, + span = span, today = s.today, + initialStripHeight = handoffStrip ?: s.allDayStripHeight(), scrollState = scrollState, dragController = dragController, onEventClick = onEventClick, @@ -594,20 +539,7 @@ private fun WeekContent( onCreateAt = onCreateAt, onDrop = onDrop, ) - } else WeekPager( - pagerState = pagerState, - grid = grid, - kind = kind, - span = span, - today = s.today, - initialStripHeight = handoffStrip ?: s.allDayStripHeight(), - scrollState = scrollState, - dragController = dragController, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) + } } } TimelineDragOverlay(dragController) @@ -934,68 +866,6 @@ private fun TimelinePageContent( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun WeekTopBar( - kind: TimelineKind, - weekStart: LocalDate, - weekEnd: LocalDate, - currentYear: Int, - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - onJumpToDate: (LocalDate) -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - val locale = currentLocale() - val (title, shortTitle) = remember(kind, weekStart, weekEnd, locale, currentYear) { - kind.title(weekStart, weekEnd, locale, currentYear, abbreviated = false) to - kind.title(weekStart, weekEnd, locale, currentYear, abbreviated = true) - } - TopAppBar( - title = { - CalendarTitleButton( - title = title, - currentDate = weekStart, - onJumpToDate = onJumpToDate, - shortTitle = shortTitle, - ) - }, - navigationIcon = { - IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(R.string.month_open_menu), - ) - } - }, - actions = { - TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search_action), - ) - } - ViewSwitcherPill( - current = selectedView, - cycle = quickSwitchViews, - onCycle = onCycleView, - ) - }, - // Deliberately flat: M3 lifts the bar to mark content scrolling under - // it, but here the bar meets the header on the same surface and the - // tint is what makes that seam look like a separate block (#186). - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) -} - @Composable private fun WeekDayHeader( geometry: ColumnGeometry, @@ -1246,16 +1116,14 @@ private fun Timeline( derivedStateOf { if (geometry.progress() >= 0.5f) geometry.to.count else geometry.from.count } } val columnWidth = (maxWidth - TIMELINE_CONTENT_END_INSET + COLUMN_GAP) / textColumns - COLUMN_GAP - TimelineGrounds( - days = geometry.dates, - scrollState = scrollState, - hourHeight = hourHeight, - columnGap = COLUMN_GAP, - modifier = Modifier - .fillMaxSize() - .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)), - ) + // What the scroll shows, known before it is laid out: a view switch + // picks what travels on its first frame. + val opening = LocalOpeningTimeline.current + val viewportPx = with(density) { maxHeight.toPx() } + val contentPx = with(density) { totalHeight.toPx() } + val visibleTop: () -> Float = remember(opening, scrollState, viewportPx, contentPx) { + { opening?.top(viewportPx, contentPx) ?: scrollState.value.toFloat() } + } // 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 @@ -1300,6 +1168,8 @@ private fun Timeline( today = today, hourHeight = hourHeight, columnWidth = columnWidth, + visibleTop = visibleTop, + viewportHeight = viewportPx, dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, @@ -1319,13 +1189,17 @@ private fun DayColumnCard( today: LocalDate, hourHeight: Dp, columnWidth: Dp, + visibleTop: () -> Float, + viewportHeight: Float, dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, modifier: Modifier = Modifier, ) { - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } + val density = LocalDensity.current + val hourPx = with(density) { hourHeight.toPx() } + val morphing = LocalViewMorph.current != null val showHourGrid = LocalShowHourGrid.current val zone = remember { TimeZone.currentSystemDefault() } // Tells a settled drop when this column has caught up with it. @@ -1401,6 +1275,17 @@ private fun DayColumnCard( val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) val blockHeight = (place.height - topInset - bottomInset) .coerceAtLeast(0.dp) + // Only a block wholly on screen travels in a view switch: the + // rest would cross the header on their way. They fade with + // their screen, and the other view leaves them be too. + val travels = morphing && rememberInViewport( + visibleTop = visibleTop, + viewportHeight = viewportHeight, + top = with(density) { (top + topInset).toPx() }, + bottom = with(density) { (top + height - bottomInset).toPx() }, + ) + val morphKey = ViewMorphKey.event(block.event, date) + StayPut(morphKey, stays = morphing && !travels) EventBlock( block = block, dark = dark, @@ -1415,7 +1300,7 @@ private fun DayColumnCard( modifier = Modifier .laneSlot(place) .offset(y = place.y + topInset) - .morphEvent(ViewMorphKey.event(block.event, date)) + .then(if (travels) Modifier.morphEvent(morphKey) else Modifier) .height(blockHeight) .padding(horizontal = BLOCK_OUTER_INSET), ) @@ -1429,6 +1314,15 @@ private fun DayColumnCard( } } +/** Whether [top]..[bottom], in pixels down the scrolled content, lies wholly in its viewport. */ +@Composable +private fun rememberInViewport(visibleTop: () -> Float, viewportHeight: Float, top: Float, bottom: Float): Boolean { + val inView by remember(visibleTop, viewportHeight, top, bottom) { + derivedStateOf { visibleTop().let { top >= it && bottom <= it + viewportHeight } } + } + return inView +} + @Composable private fun EventBlock( block: TimedBlock, 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 2acd4ef..0768529 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 @@ -14,6 +14,9 @@ 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.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -164,6 +167,20 @@ abstract class PagedTimelineViewModel( fun goToToday() = jumpTo(todayDate) + /** + * Moves to [date] and waits for its page to load, so a switch into this view + * opens on its events instead of watching them arrive mid-way (#184). + */ + suspend fun prepare(date: LocalDate) { + jumpTo(date) + val position = positionOf(date, rule.value) + val page = DaySpan(position.grid.startForPage(position.page), position.grid.dayCount) + coroutineScope { + launch { state.first { it !is WeekUiState.Loading } } + span(page).first { it !is WeekUiState.Loading } + } + } + private fun buildState( span: DaySpan, calendars: List, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f31a061..f444241 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -111,9 +111,10 @@ gesture finishes from its scaled preview. - **Across screens, what both show morphs.** Inside a `SharedTransitionLayout`, an event shown in both views travels from its old bounds to its new ones, keyed per occurrence *and* per day it is drawn from (`ViewMorphKey`); only a - pager's settled page carries the tags. The top bar's shared pieces and the - create FAB are tagged too (`morphChrome`), so they hold still while the - rest cross-fades — except over an open drawer. + pager's settled page carries the tags. The drawer, top bar and FAB are not + per screen: CalendarHost draws them once, above the switch, from what the + screen on show publishes (`PublishChrome` in `CalendarChrome.kt`), so a + switch only changes what they say. A tapped reminder notification routes through `MainActivity` (`singleTop` + `onNewIntent`) as an external detail key that `CalendarHost` consumes exactly like an event tap. From 51b9c56c3c62e18b84fa90ae25b886a29693513f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 30 Sep 2026 13:28:47 +0200 Subject: [PATCH 17/17] Refine view switching and timeline morph (#184) --- .../calendula/ui/CalendarHost.kt | 53 +++----- .../calendula/ui/agenda/AgendaScreen.kt | 11 +- .../calendula/ui/common/HourGrid.kt | 6 +- .../calendula/ui/common/ViewFocus.kt | 8 ++ .../calendula/ui/month/MonthScreen.kt | 48 +++++-- .../calendula/ui/week/WeekScreen.kt | 121 ++++++++++++++---- .../calendula/ui/week/WeekViewModel.kt | 17 --- 7 files changed, 164 insertions(+), 100 deletions(-) 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 c5b2f59..6f7b13c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver @@ -48,10 +49,6 @@ import de.jeanlucmakiola.calendula.ui.common.ChromeHost import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot import de.jeanlucmakiola.calendula.ui.common.next -import de.jeanlucmakiola.calendula.ui.week.DayViewModel -import de.jeanlucmakiola.calendula.ui.week.MultiDayViewModel -import de.jeanlucmakiola.calendula.ui.week.TimelineViewModels -import de.jeanlucmakiola.calendula.ui.week.WeekViewModel import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.identity.rememberReduceMotion @@ -83,7 +80,6 @@ import de.jeanlucmakiola.calendula.ui.search.SearchScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen import de.jeanlucmakiola.calendula.ui.week.TimelineScreen import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -142,6 +138,7 @@ fun CalendarHost( var viewStack by rememberSaveable(stateSaver = viewStackSaver) { mutableStateOf(listOf(defaultView)) } + var navigationVersion by remember { mutableIntStateOf(0) } val view = viewStack.last() // The timeline view last shown, so the timeline leaving for another screen // keeps drawing the view it was on while it goes. @@ -155,28 +152,15 @@ fun CalendarHost( // anywhere focuses it and opens the day view there. val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } - // Into the timeline from another screen, its page is loaded first, briefly, - // so its events are there to morph into rather than arriving mid-way. - val timelineModels = TimelineViewModels( - hiltViewModel(), - hiltViewModel(), - hiltViewModel(), - ) val switchScope = rememberCoroutineScope() - val switchTo: (CalendarView, (List) -> List) -> Unit = { target, move -> - if (target.screen == ViewScreen.Timeline && viewStack.last().screen != ViewScreen.Timeline) { - switchScope.launch { - withTimeoutOrNull(VIEW_DATA_WAIT_MS) { timelineModels[target].prepare(focus.date) } - viewStack = move(viewStack) - } - } else { - viewStack = move(viewStack) - } + val switchTo: ((List) -> List) -> Unit = { move -> + navigationVersion++ + viewStack = move(viewStack) } - val onSelectView: (CalendarView) -> Unit = { target -> switchTo(target) { it.selectView(target) } } + val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } } val onOpenDay: (LocalDate) -> Unit = { date -> - focus.date = date - switchTo(CalendarView.Day) { it.drillToDay() } + focus.navigateTo(date) + switchTo { it.drillToDay() } } // The event-detail screen (S4) is a full-screen destination hoisted here so @@ -326,13 +310,14 @@ fun CalendarHost( // [requestedDetailKey]. Date/event opens root the stack in the widget's own // view so backing out returns there (then home), not to the default. LaunchedEffect(widgetNavRequest) { + if (widgetNavRequest != null) navigationVersion++ when (val req = widgetNavRequest) { is WidgetNavRequest.OpenDate -> { // Drill the day view in over the widget's view: drop any overlay // that would cover it, so the open doesn't land under Settings/form. dismissCoveringOverlays() createDateIso = null - focus.date = LocalDate.parse(req.dateIso) + focus.navigateTo(LocalDate.parse(req.dateIso)) // No widget source (an external date tap) roots over the default // home view, so backing out of the day returns home then exits. viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay() @@ -356,7 +341,7 @@ fun CalendarHost( // default home — so backing out returns to the default, then exits. dismissCoveringOverlays() createDateIso = null - focus.date = localToday() + focus.navigateTo(localToday()) viewStack = viewBaseStack(defaultView, req.view ?: defaultView) onWidgetNavConsumed() } @@ -418,6 +403,7 @@ fun CalendarHost( editKey != null || showSettings || showCalendars || showBackup || importUri != null || importForm != null BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) { + navigationVersion++ viewStack = viewStack.dropLast(1) } @@ -444,7 +430,7 @@ fun CalendarHost( LocalViewFocus provides focus, // The failure state's way out when every calendar is switched off // (#239); nothing else in the calendar surfaces navigates here. - LocalManageCalendars provides { showCalendars = true }, + LocalManageCalendars provides remember { { showCalendars = true } }, ) { // One drawer, top bar and FAB for every screen (#184): a switch changes // what they say, from what the screen on show publishes, rather than @@ -462,12 +448,10 @@ fun CalendarHost( viewOrder = drawerViewOrder, onSelectView = { target -> // Once the drawer is out of the way, so the switch is seen whole. + val requestVersion = ++navigationVersion switchScope.launch { - try { - drawerState.close() - } finally { - onSelectView(target) - } + drawerState.close() + if (navigationVersion == requestVersion) onSelectView(target) } }, onJumpToDate = { target -> @@ -487,7 +471,7 @@ fun CalendarHost( host = chromeHost, selectedView = view, quickSwitchViews = quickSwitchViews, - onCycleView = { onSelectView(view.next(quickSwitchViews)) }, + onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) }, onOpenDrawer = { switchScope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, showTodayButton = todayInToolbar, @@ -748,6 +732,3 @@ private val viewStackSaver = listSaver, Int>( private fun localToday(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date - -/** How long a switch into the timeline waits for its page before going anyway. */ -private const val VIEW_DATA_WAIT_MS = 200L diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 209867f..737b023 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -87,12 +87,8 @@ fun AgendaScreen( // Opens on the focused date and hands back the day scrolled to the top. val focus = LocalViewFocus.current EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) } - val loadedState by viewModel.state.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle() - // Rows from before the anchor moved belong to another window: they would - // show the wrong days, and the view switch would match the wrong events. - val state = loadedState.takeUnless { it is AgendaUiState.Success && it.anchor != anchor } - ?: AgendaUiState.Loading val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() @@ -195,7 +191,9 @@ fun AgendaScreen( onRetry = viewModel::goToToday, onEventClick = onEventClick, onOpenDay = onOpenDay, - onTopDay = { focus?.date = it }, + onTopDay = if (successState?.anchor == anchor) { + { focus?.date = it } + } else null, modifier = Modifier .weight(1f) .fillMaxWidth(), @@ -442,4 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { ) } } - diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt index 82e4e96..fe933e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt @@ -57,14 +57,12 @@ internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Fl * drawn across one. Applied to a day column's content, so the cells sit over the * column background but beneath the event blocks — blocks stay in a continuous * coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel - * height; [color] is resolved by the caller from the theme. [offsetPx] shifts - * the cells up, for a fixed window over a scrolled column. + * height; [color] is resolved by the caller from the theme. */ fun Modifier.hourGridCells( show: Boolean, hourHeightPx: Float, color: Color, - offsetPx: () -> Float = { 0f }, ): Modifier = if (!show) { this @@ -77,7 +75,7 @@ fun Modifier.hourGridCells( hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()), ) val cellSize = Size(size.width, cellHeight) - val top = inset - offsetPx() + val top = inset for (hour in 0 until 24) { drawRoundRect( color = color, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt index 2393e48..27bf4a0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt @@ -21,6 +21,14 @@ import kotlinx.datetime.LocalDate @Stable class ViewFocus(initial: LocalDate) { var date by mutableStateOf(initial) + /** Explicit navigation, including a request for the view already on screen. */ + var navigationVersion by mutableStateOf(0) + private set + + fun navigateTo(target: LocalDate) { + date = target + navigationVersion++ + } } val ViewFocusSaver: Saver = Saver( 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 953066c..82fc026 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 @@ -85,6 +85,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -215,6 +216,8 @@ fun MonthScreen( val scope = rememberCoroutineScope() val scrolling = viewStyle.isScrolling + var pendingScrollMonth by remember { mutableStateOf(null) } + var scrollRequestId by remember { mutableIntStateOf(0) } // 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 @@ -312,9 +315,14 @@ fun MonthScreen( // back there rather than staying on the month you were looking at. LaunchedEffect(listState, scrolling, dense, weekStart) { if (!scrolling) return@LaunchedEffect - snapshotFlow { visibleMonth } + snapshotFlow { visibleMonth to pendingScrollMonth } .distinctUntilChanged() - .collect { viewModel.syncScrollMonth(it) } + .collect { (visible, pending) -> + if (pending == null || pending == visible) { + viewModel.syncScrollMonth(visible) + if (pending == visible) pendingScrollMonth = null + } + } } val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) @@ -341,11 +349,21 @@ fun MonthScreen( } val jumpToToday: () -> Unit = { if (scrolling) { + val targetMonth = YearMonth(today.year, today.month) + pendingScrollMonth = targetMonth + val requestId = ++scrollRequestId + viewModel.goToToday() scope.launch { - listState.animateScrollToItem( - if (dense) weekIndexOf(today, weekStart) - else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), - ) + try { + listState.animateScrollToItem( + if (dense) weekIndexOf(today, weekStart) + else itemIndexForMonth(monthIndexOf(targetMonth)), + ) + } finally { + if (scrollRequestId == requestId) { + pendingScrollMonth = null + } + } } } else { viewModel.goToToday() @@ -355,11 +373,21 @@ fun MonthScreen( // Drawer jump-to-date. val jumpToDate: (LocalDate) -> Unit = { target -> if (scrolling) { + val targetMonth = YearMonth(target.year, target.month) + pendingScrollMonth = targetMonth + val requestId = ++scrollRequestId + viewModel.goToDate(target) scope.launch { - listState.animateScrollToItem( - if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) - else itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), - ) + try { + listState.animateScrollToItem( + if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) + else itemIndexForMonth(monthIndexOf(targetMonth)), + ) + } finally { + if (scrollRequestId == requestId) { + pendingScrollMonth = null + } + } } } else { viewModel.goToDate(target) 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 14c00d8..fbb934d 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 @@ -68,6 +68,7 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.first import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.SideEffect import de.jeanlucmakiola.calendula.ui.common.laneSlot import de.jeanlucmakiola.calendula.ui.common.LaneColumn @@ -77,6 +78,8 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned @@ -298,6 +301,8 @@ private fun PagedTimelineScreen( // Opens on the focused date, then carries it along as the pages move. val focus = LocalViewFocus.current + val navigationVersion = focus?.navigationVersion ?: 0 + var handledNavigation by remember { mutableIntStateOf(navigationVersion) } EnterOnFocus(viewModel) { viewModel.jumpTo(it) } // Keyed on the view model: collected unkeyed, a switch would read the view // before's anchor and grid for a frame and open its pager. @@ -307,6 +312,9 @@ private fun PagedTimelineScreen( var lastState by remember { mutableStateOf(WeekUiState.Loading) } val state = if (loadedState is WeekUiState.Loading) lastState else loadedState SideEffect { if (loadedState !is WeekUiState.Loading) lastState = loadedState } + LaunchedEffect(shown, loadedState) { + if (loadedState is WeekUiState.Success) handoffStrip = null + } val anchor by key(viewModel) { viewModel.anchor.collectAsStateWithLifecycle() } val rule by key(viewModel) { viewModel.rule.collectAsStateWithLifecycle() } val position = remember(anchor, rule) { positionOf(anchor, rule) } @@ -357,7 +365,15 @@ private fun PagedTimelineScreen( val currentPager by rememberUpdatedState(pagerState) val currentGrid by rememberUpdatedState(grid) val currentState by rememberUpdatedState(state) - LaunchedEffect(view) { + LaunchedEffect(view, navigationVersion) { + if (navigationVersion != handledNavigation) { + handledNavigation = navigationVersion + morph = null + handoffStrip = null + viewModels[view].jumpTo(focus?.date ?: return@LaunchedEffect) + shown = view + return@LaunchedEffect + } // A switch interrupted by another lands first, and this one starts from there. snapshotFlow { morph }.first { it == null } withFrameNanos { } @@ -382,9 +398,9 @@ private fun PagedTimelineScreen( withTimeoutOrNull(MORPH_DATA_WAIT_MS) { toLoaded.first { it !is WeekUiState.Loading } } morph = switch switch.progress.animateTo(1f, if (switch.isLong) longSwitchSpec else switchSpec) - } finally { handoffStrip = (toLoaded.value as? WeekUiState.Success)?.allDayStripHeight() shown = view + } finally { morph = null } } @@ -656,19 +672,30 @@ private fun TimelineMorphFrame( side?.timedByDay?.get(date).orEmpty() } } - val progress = morph.progress.value val fromStrip = from?.allDayStripHeight() ?: 0.dp val toStrip = to?.allDayStripHeight() ?: fromStrip - val allDayHeight = androidx.compose.ui.unit.lerp(fromStrip, toStrip, progress.coerceIn(0f, 1f)) - val weekNumbers = listOf(morph.fromKind, morph.toKind).map { if (it.showWeekNumber) 1f else 0f } - val weekNumberAlpha = lerp(weekNumbers[0], weekNumbers[1], progress).coerceIn(0f, 1f) + val animatedStrip = remember(morph, fromStrip, toStrip) { + { androidx.compose.ui.unit.lerp(fromStrip, toStrip, morph.progress.value.coerceIn(0f, 1f)) } + } + val fromBadge = if (morph.fromKind.showWeekNumber) 1f else 0f + val toBadge = if (morph.toKind.showWeekNumber) 1f else 0f + val badgeAlpha = remember(morph) { + { lerp(fromBadge, toBadge, morph.progress.value).coerceIn(0f, 1f) } + } + // The header changes its alignment once; its columns and bars move in layout. + val headerBias by remember(morph) { + derivedStateOf { + if (morph.progress.value < 0.5f) morph.fromKind.headerBias else morph.toKind.headerBias + } + } // The events travel inside the columns here, not between views. CompositionLocalProvider(LocalViewMorph provides null) { WeekFrame( weekStart = if (morph.toKind.showWeekNumber) morph.to.start else morph.from.start, - showWeekNumber = weekNumberAlpha > 0f, - allDayHeight = allDayHeight, - weekNumberAlpha = weekNumberAlpha, + showWeekNumber = morph.fromKind.showWeekNumber || morph.toKind.showWeekNumber, + allDayHeight = fromStrip, + animatedAllDayHeight = animatedStrip, + weekNumberAlpha = badgeAlpha, scrollState = scrollState, dragController = dragController, ) { @@ -677,8 +704,9 @@ private fun TimelineMorphFrame( today = today, timedFor = timedFor, bars = bars, - headerBias = lerp(morph.fromKind.headerBias, morph.toKind.headerBias, progress.coerceIn(0f, 1f)), - allDayHeight = allDayHeight, + headerBias = headerBias, + allDayHeight = fromStrip, + animatedAllDayHeight = animatedStrip, scrollState = scrollState, dragController = dragController, active = false, @@ -691,6 +719,23 @@ private fun TimelineMorphFrame( } } +/** Read an animated strip height during measurement, without composing its contents again. */ +private fun Modifier.dynamicHeight(height: () -> Dp): Modifier = layout { measurable, constraints -> + val pixels = height().roundToPx().coerceIn(constraints.minHeight, constraints.maxHeight) + val placeable = measurable.measure(constraints.copy(minHeight = pixels, maxHeight = pixels)) + layout(placeable.width, pixels) { placeable.place(0, 0) } +} + +/** Keep a morph's expensive viewport content measured at one size while its visible edge moves. */ +private fun Modifier.stableMorphViewport(stripHeight: () -> Dp, fullHeight: IntArray): Modifier = + clipToBounds().layout { measurable, constraints -> + if (fullHeight[0] < 0) fullHeight[0] = constraints.maxHeight + stripHeight().roundToPx() + val measured = measurable.measure( + constraints.copy(minHeight = fullHeight[0], maxHeight = fullHeight[0]), + ) + layout(constraints.maxWidth, constraints.maxHeight) { measured.place(0, 0) } + } + /** * What stays put while the weeks swipe: the week-number badge and the hour * gutter, with [pages] filling the rest. @@ -700,11 +745,13 @@ private fun WeekFrame( weekStart: LocalDate, showWeekNumber: Boolean, allDayHeight: Dp, - weekNumberAlpha: Float = 1f, + animatedAllDayHeight: (() -> Dp)? = null, + weekNumberAlpha: () -> Float = { 1f }, scrollState: ScrollState, dragController: TimelineDragController, pages: @Composable () -> Unit, ) { + val gutterFullHeight = remember(animatedAllDayHeight) { intArrayOf(-1) } Row(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) { Column( @@ -715,16 +762,24 @@ private fun WeekFrame( WeekNumberCell( weekStart = weekStart, showBadge = showWeekNumber, - modifier = if (weekNumberAlpha < 1f) Modifier.alpha(weekNumberAlpha) else Modifier, + modifier = if (animatedAllDayHeight == null) Modifier + else Modifier.graphicsLayer { alpha = weekNumberAlpha() }, ) - Spacer(Modifier.height(allDayHeight)) + Spacer(if (animatedAllDayHeight == null) Modifier.height(allDayHeight) + else Modifier.dynamicHeight(animatedAllDayHeight)) } 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()) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize().then( + if (animatedAllDayHeight == null) Modifier + else Modifier.stableMorphViewport(animatedAllDayHeight, gutterFullHeight), + ), + ) { val zoom = LocalTimelineZoom.current - val fitHeight = maxHeight + allDayHeight + val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight + else maxHeight val hourHeight = zoom.scale.hourHeight(fitHeight) HourGutter( scrollState = scrollState, @@ -827,6 +882,7 @@ private fun TimelinePageContent( bars: List, headerBias: Float, allDayHeight: Dp, + animatedAllDayHeight: (() -> Dp)? = null, scrollState: ScrollState, dragController: TimelineDragController, active: Boolean, @@ -847,7 +903,8 @@ private fun TimelinePageContent( CompositionLocalProvider( LocalChipGround provides MaterialTheme.colorScheme.surface, ) { - AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, onEventClick = onEventClick) + AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, + animatedHeight = animatedAllDayHeight, onEventClick = onEventClick) } } Spacer(Modifier.height(TIMELINE_TOP_GAP)) @@ -856,6 +913,7 @@ private fun TimelinePageContent( today = today, timedFor = timedFor, allDayHeight = allDayHeight, + animatedAllDayHeight = animatedAllDayHeight, scrollState = scrollState, dragController = dragController, active = active, @@ -984,6 +1042,7 @@ private fun AllDayStrip( geometry: ColumnGeometry, bars: List, height: Dp, + animatedHeight: (() -> Dp)? = null, onEventClick: (EventInstance) -> Unit, ) { val dark = isSystemInDarkTheme() @@ -993,7 +1052,7 @@ private fun AllDayStrip( .fillMaxWidth() // Height is hoisted + animated so it slides and resizes smoothly; // padding sits inside it so the content area is lanes * row height. - .height(height) + .then(if (animatedHeight == null) Modifier.height(height) else Modifier.dynamicHeight(animatedHeight)) .padding( top = ALL_DAY_VERTICAL_PADDING, bottom = ALL_DAY_VERTICAL_PADDING, @@ -1080,6 +1139,7 @@ private fun Timeline( today: LocalDate, timedFor: (LocalDate) -> List, allDayHeight: Dp, + animatedAllDayHeight: (() -> Dp)? = null, scrollState: ScrollState, dragController: TimelineDragController, active: Boolean, @@ -1087,6 +1147,7 @@ private fun Timeline( onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, ) { + val fullViewportHeight = remember(geometry) { intArrayOf(-1) } val dark = isSystemInDarkTheme() val zoom = LocalTimelineZoom.current val density = LocalDensity.current @@ -1102,8 +1163,13 @@ private fun Timeline( // timeline's own viewport height, which is only known here — below the top // bar, day header and all-day strip. The strip is given back, so the scale // holds while paging and matches the day view's (#184). - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val fitHeight = maxHeight + allDayHeight + BoxWithConstraints( + modifier = Modifier.fillMaxSize().then( + if (animatedAllDayHeight == null) Modifier + else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight), + ), + ) { + val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight val hourHeight = zoom.scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The pinch sits above the scroll viewport: it has to outrank the @@ -1120,9 +1186,12 @@ private fun Timeline( // picks what travels on its first frame. val opening = LocalOpeningTimeline.current val viewportPx = with(density) { maxHeight.toPx() } + val visibleViewport: () -> Float = remember(animatedAllDayHeight, viewportPx, density) { + { viewportPx - (animatedAllDayHeight?.let { with(density) { it().toPx() } } ?: 0f) } + } val contentPx = with(density) { totalHeight.toPx() } - val visibleTop: () -> Float = remember(opening, scrollState, viewportPx, contentPx) { - { opening?.top(viewportPx, contentPx) ?: scrollState.value.toFloat() } + val visibleTop: () -> Float = remember(opening, scrollState, visibleViewport, contentPx) { + { opening?.top(visibleViewport(), contentPx) ?: scrollState.value.toFloat() } } // Scrolls on the same state as the gutter and the other pages, so they // all stay aligned. A static, rounded-clipped window — the content @@ -1169,7 +1238,7 @@ private fun Timeline( hourHeight = hourHeight, columnWidth = columnWidth, visibleTop = visibleTop, - viewportHeight = viewportPx, + viewportHeight = visibleViewport, dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, @@ -1190,7 +1259,7 @@ private fun DayColumnCard( hourHeight: Dp, columnWidth: Dp, visibleTop: () -> Float, - viewportHeight: Float, + viewportHeight: () -> Float, dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, @@ -1316,9 +1385,9 @@ private fun DayColumnCard( /** Whether [top]..[bottom], in pixels down the scrolled content, lies wholly in its viewport. */ @Composable -private fun rememberInViewport(visibleTop: () -> Float, viewportHeight: Float, top: Float, bottom: Float): Boolean { +private fun rememberInViewport(visibleTop: () -> Float, viewportHeight: () -> Float, top: Float, bottom: Float): Boolean { val inView by remember(visibleTop, viewportHeight, top, bottom) { - derivedStateOf { visibleTop().let { top >= it && bottom <= it + viewportHeight } } + derivedStateOf { visibleTop().let { top >= it && bottom <= it + viewportHeight() } } } return inView } 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 0768529..2acd4ef 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 @@ -14,9 +14,6 @@ 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.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.flow.first import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -167,20 +164,6 @@ abstract class PagedTimelineViewModel( fun goToToday() = jumpTo(todayDate) - /** - * Moves to [date] and waits for its page to load, so a switch into this view - * opens on its events instead of watching them arrive mid-way (#184). - */ - suspend fun prepare(date: LocalDate) { - jumpTo(date) - val position = positionOf(date, rule.value) - val page = DaySpan(position.grid.startForPage(position.page), position.grid.dayCount) - coroutineScope { - launch { state.first { it !is WeekUiState.Loading } } - span(page).first { it !is WeekUiState.Loading } - } - } - private fun buildState( span: DaySpan, calendars: List,