Resize timeline columns in place when switching views (#184)

This commit is contained in:
2026-09-27 19:26:24 +02:00
parent 6ed0b30230
commit 7d7d16ca31
7 changed files with 341 additions and 144 deletions
@@ -20,6 +20,9 @@ import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity 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.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack 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.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.edit.ImportSource 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.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.MultiDayScreen import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -121,6 +122,13 @@ fun CalendarHost(
mutableStateOf(listOf(defaultView)) mutableStateOf(listOf(defaultView))
} }
val view = viewStack.last() 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) } val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// The date every view opens on and carries along (#184); tapping a day // The date every view opens on and carries along (#184); tapping a day
@@ -400,20 +408,23 @@ fun CalendarHost(
.fillMaxSize() .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
// among them must not replay this transition.
AnimatedContent( AnimatedContent(
targetState = view, targetState = view.screen,
transitionSpec = { viewSwitch }, transitionSpec = { viewSwitch },
label = "view-switch", label = "view-switch",
) { currentView -> ) { screen ->
val sharedScope = this@SharedTransitionLayout val sharedScope = this@SharedTransitionLayout
val visibilityScope = this@AnimatedContent val visibilityScope = this@AnimatedContent
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip) { val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip) {
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip) if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip)
} }
CompositionLocalProvider(LocalViewMorph provides viewMorph) { CompositionLocalProvider(LocalViewMorph provides viewMorph) {
when (currentView) { when (screen) {
CalendarView.Week -> WeekScreen( ViewScreen.Timeline -> TimelineScreen(
selectedView = currentView, view = timelineView,
onSelectView = onSelectView, onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -424,8 +435,8 @@ fun CalendarHost(
drawerViewOrder = drawerViewOrder, drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar, todayInToolbar = todayInToolbar,
) )
CalendarView.MultiDay -> MultiDayScreen( ViewScreen.Month -> MonthScreen(
selectedView = currentView, selectedView = CalendarView.Month,
onSelectView = onSelectView, onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -436,31 +447,8 @@ fun CalendarHost(
drawerViewOrder = drawerViewOrder, drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar, todayInToolbar = todayInToolbar,
) )
CalendarView.Day -> DayScreen( ViewScreen.Agenda -> AgendaScreen(
selectedView = currentView, selectedView = CalendarView.Agenda,
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,
onSelectView = onSelectView, onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -1,5 +1,10 @@
package de.jeanlucmakiola.calendula.ui.common 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.clickable
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -71,12 +76,24 @@ fun CalendarTitleButton(
availableWidth = constraints.maxWidth, availableWidth = constraints.maxWidth,
) )
} }
Text( // Cross-fades, so switching views or paging turns the title over
text = shown, // rather than snapping it (#184).
style = style, val fadeSpec = rememberCalendarFadeSpec()
maxLines = 1, AnimatedContent(
overflow = TextOverflow.Ellipsis, 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( Icon(
imageVector = Icons.Default.ArrowDropDown, imageVector = Icons.Default.ArrowDropDown,
@@ -129,3 +129,13 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
/** Push the day view as a drill-in over the current stack (no-op if already on it). */ /** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> = fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day 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
}
@@ -1,10 +1,16 @@
package de.jeanlucmakiola.calendula.ui.common 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.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -30,11 +36,23 @@ fun ViewSwitcherPill(
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset, trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
) { ) {
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
val fadeSpec = rememberCalendarFadeSpec()
FilledTonalButton( FilledTonalButton(
onClick = onCycle, onClick = onCycle,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
modifier = modifier.padding(end = trailingInset), 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)
}
} }
} }
@@ -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<WeekUiState>,
val toState: StateFlow<WeekUiState>,
) {
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 }
}
@@ -68,6 +68,13 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier 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.laneSlot
import de.jeanlucmakiola.calendula.ui.common.LaneColumn import de.jeanlucmakiola.calendula.ui.common.LaneColumn
import kotlin.math.roundToInt 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 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 @Composable
fun WeekScreen( fun TimelineScreen(
selectedView: CalendarView, view: CalendarView,
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -224,84 +235,41 @@ fun WeekScreen(
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS, drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false, todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(), weekViewModel: WeekViewModel = hiltViewModel(),
) = PagedTimelineScreen( multiDayViewModel: MultiDayViewModel = hiltViewModel(),
viewModel = viewModel, dayViewModel: DayViewModel = hiltViewModel(),
kind = TimelineKind.Week, ) {
selectedView = selectedView, val viewModels = remember(weekViewModel, multiDayViewModel, dayViewModel) {
onSelectView = onSelectView, TimelineViewModels(weekViewModel, multiDayViewModel, dayViewModel)
onOpenDay = onOpenDay, }
onEventClick = onEventClick, PagedTimelineScreen(
onOpenSettings = onOpenSettings, view = view,
onOpenSearch = onOpenSearch, viewModels = viewModels,
onCreateEvent = onCreateEvent, onSelectView = onSelectView,
quickSwitchViews = quickSwitchViews, onOpenDay = onOpenDay,
drawerViewOrder = drawerViewOrder, onEventClick = onEventClick,
todayInToolbar = todayInToolbar, onOpenSettings = onOpenSettings,
modifier = modifier, 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. */ /** Each timeline view's own pages and anchor, held side by side so a switch can show both. */
@Composable internal class TimelineViewModels(
fun MultiDayScreen( private val week: WeekViewModel,
selectedView: CalendarView, private val multiDay: MultiDayViewModel,
onSelectView: (CalendarView) -> Unit, private val day: DayViewModel,
onOpenDay: (LocalDate) -> Unit, ) {
onEventClick: (EventInstance) -> Unit, operator fun get(view: CalendarView): PagedTimelineViewModel = when (view) {
onOpenSettings: () -> Unit, CalendarView.MultiDay -> multiDay
onOpenSearch: () -> Unit, CalendarView.Day -> day
onCreateEvent: (LocalDate, Int?) -> Unit, else -> week
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, }
drawerViewOrder: List<CalendarView> = 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<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = 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,
)
/** What sets the timeline views apart; everything else they share. */ /** What sets the timeline views apart; everything else they share. */
internal enum class TimelineKind( 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun PagedTimelineScreen( private fun PagedTimelineScreen(
viewModel: PagedTimelineViewModel, view: CalendarView,
kind: TimelineKind, viewModels: TimelineViewModels,
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -342,10 +319,23 @@ private fun PagedTimelineScreen(
todayInToolbar: Boolean, todayInToolbar: Boolean,
modifier: Modifier, 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<TimelineMorph?>(null) }
// The strip height a switch ended on, for the pager it hands over to.
var handoffStrip by remember { mutableStateOf<Dp?>(null) }
val viewModel = viewModels[shown]
val kind = shown.timelineKind
// Opens on the focused date, then carries it along as the pages move. // Opens on the focused date, then carries it along as the pages move.
val focus = LocalViewFocus.current val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { viewModel.jumpTo(it) } 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>(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 anchor by viewModel.anchor.collectAsStateWithLifecycle()
val rule by viewModel.rule.collectAsStateWithLifecycle() val rule by viewModel.rule.collectAsStateWithLifecycle()
val position = remember(anchor, rule) { positionOf(anchor, rule) } val position = remember(anchor, rule) { positionOf(anchor, rule) }
@@ -364,9 +354,9 @@ private fun PagedTimelineScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// A new grid numbers the pages differently, so it gets a pager of its own, // 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, // opened straight on the anchor's page: another view, the first day of the
// or a multi-day jump moving another date into the first column. // week changing, or a multi-day jump moving another date into the first column.
val pagerState = key(grid) { val pagerState = key(viewModel, grid) {
rememberPagerState(initialPage = anchorPage) { grid.pageCount } rememberPagerState(initialPage = anchorPage) { grid.pageCount }
} }
val pageSpec = rememberCalendarPageSpec() val pageSpec = rememberCalendarPageSpec()
@@ -387,9 +377,52 @@ private fun PagedTimelineScreen(
focus?.let { it.date = grid.focusOnPage(it.date, 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) // Switching to another timeline view resizes the columns into it (#184):
val weekEnd = weekStart.plus(grid.dayCount - 1, kotlinx.datetime.DateTimeUnit.DAY) // 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 // 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. // midnight; derived, so the per-minute tick doesn't recompose the screen.
@@ -417,12 +450,12 @@ private fun PagedTimelineScreen(
gesturesEnabled = drawerState.isOpen, gesturesEnabled = drawerState.isOpen,
drawerContent = { drawerContent = {
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = view,
currentDate = weekStart, currentDate = weekStart,
drawerState = drawerState, drawerState = drawerState,
viewOrder = drawerViewOrder, viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { target ->
onSelectView(view) onSelectView(target)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
}, },
onJumpToDate = { target -> onJumpToDate = { target ->
@@ -440,12 +473,12 @@ private fun PagedTimelineScreen(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
WeekTopBar( WeekTopBar(
kind = kind, kind = titleKind,
weekStart = weekStart, weekStart = weekStart,
weekEnd = weekEnd, weekEnd = weekEnd,
currentYear = today.year, currentYear = today.year,
selectedView = selectedView, selectedView = view,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(view.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
@@ -457,10 +490,10 @@ private fun PagedTimelineScreen(
floatingActionButton = { floatingActionButton = {
CalendarFabColumn( CalendarFabColumn(
todayVisible = !isOnCurrentWeek && !todayInToolbar, todayVisible = !isOnCurrentWeek && !todayInToolbar,
todayText = stringResource(kind.todayText), todayText = stringResource(titleKind.todayText),
onToday = jumpToToday, onToday = jumpToToday,
onCreate = { 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) onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
}, },
) )
@@ -472,6 +505,8 @@ private fun PagedTimelineScreen(
pagerState = pagerState, pagerState = pagerState,
grid = grid, grid = grid,
kind = kind, kind = kind,
morph = morph,
handoffStrip = handoffStrip,
span = viewModel::span, span = viewModel::span,
onRetry = viewModel::goToToday, onRetry = viewModel::goToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -492,6 +527,8 @@ private fun WeekContent(
pagerState: PagerState, pagerState: PagerState,
grid: PageGrid, grid: PageGrid,
kind: TimelineKind, kind: TimelineKind,
morph: TimelineMorph?,
handoffStrip: Dp?,
span: (DaySpan) -> StateFlow<WeekUiState>, span: (DaySpan) -> StateFlow<WeekUiState>,
onRetry: () -> Unit, onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -536,13 +573,24 @@ private fun WeekContent(
when (s) { when (s) {
WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount) WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount)
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) 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, pagerState = pagerState,
grid = grid, grid = grid,
kind = kind, kind = kind,
span = span, span = span,
today = s.today, today = s.today,
initialStripHeight = s.allDayStripHeight(), initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
scrollState = scrollState, scrollState = scrollState,
dragController = dragController, dragController = dragController,
onEventClick = onEventClick, 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<TimedBlock> = 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 * What stays put while the weeks swipe: the week-number badge and the hour
* gutter, with [pages] filling the rest. * gutter, with [pages] filling the rest.
@@ -646,6 +758,7 @@ private fun WeekFrame(
weekStart: LocalDate, weekStart: LocalDate,
showWeekNumber: Boolean, showWeekNumber: Boolean,
allDayHeight: Dp, allDayHeight: Dp,
weekNumberAlpha: Float = 1f,
scrollState: ScrollState, scrollState: ScrollState,
dragController: TimelineDragController, dragController: TimelineDragController,
pages: @Composable () -> Unit, pages: @Composable () -> Unit,
@@ -657,7 +770,11 @@ private fun WeekFrame(
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface), .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(allDayHeight))
} }
Spacer(Modifier.height(TIMELINE_TOP_GAP)) Spacer(Modifier.height(TIMELINE_TOP_GAP))
@@ -942,10 +1059,10 @@ private fun WeekDayHeader(
* bar's hamburger (see GUTTER_CONTENT_START_INSET). * bar's hamburger (see GUTTER_CONTENT_START_INSET).
*/ */
@Composable @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() } val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
Column( Column(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(
start = GUTTER_CONTENT_START_INSET, start = GUTTER_CONTENT_START_INSET,
@@ -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)
}
}