From 7d7d16ca3129c7f334449ccce051d4797ad6fd0e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:26:24 +0200 Subject: [PATCH] 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 5324f948..343145e8 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 305f0ecb..bdd9e9a8 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 01033d99..9f756de7 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 10e17c45..3ca60cd9 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 00000000..35c43cfe --- /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 5599dc91..8b00def9 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 00000000..538c7da0 --- /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) + } +}