Carry the timeline hours between views (#184)

This commit is contained in:
2026-09-27 19:10:49 +02:00
parent 57dcda5b54
commit 39ca7d869a
7 changed files with 254 additions and 41 deletions
@@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm 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.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen 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.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion 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.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars 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.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel 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.ViewMorphScope
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
@@ -371,13 +378,23 @@ fun CalendarHost(
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
val morphBounds = rememberViewMorphBoundsTransform() val morphBounds = rememberViewMorphBoundsTransform()
val morphFade = rememberCalendarFadeSpec() 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( CompositionLocalProvider(
LocalEventMove provides moveScope, LocalEventMove provides moveScope,
LocalTimelineScroll provides timelineScroll,
// The failure state's way out when every calendar is switched off // The failure state's way out when every calendar is switched off
// (#239); nothing else in the calendar surfaces navigates here. // (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides { showCalendars = true }, LocalManageCalendars provides { showCalendars = true },
) { ) {
SharedTransitionLayout(Modifier.fillMaxSize()) { SharedTransitionLayout(
Modifier
.fillMaxSize()
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
) {
AnimatedContent( AnimatedContent(
targetState = view, targetState = view,
transitionSpec = { viewSwitch }, transitionSpec = { viewSwitch },
@@ -385,8 +402,8 @@ fun CalendarHost(
) { currentView -> ) { currentView ->
val sharedScope = this@SharedTransitionLayout val sharedScope = this@SharedTransitionLayout
val visibilityScope = this@AnimatedContent val visibilityScope = this@AnimatedContent
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade) { val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip) {
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade) if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip)
} }
CompositionLocalProvider(LocalViewMorph provides viewMorph) { CompositionLocalProvider(LocalViewMorph provides viewMorph) {
when (currentView) { when (currentView) {
@@ -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 * 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 * column background but beneath the event blocks — blocks stay in a continuous
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel * 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) { if (!show) {
this this
} else { } else {
@@ -71,10 +77,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()), hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
) )
val cellSize = Size(size.width, cellHeight) val cellSize = Size(size.width, cellHeight)
val top = inset - offsetPx()
for (hour in 0 until 24) { for (hour in 0 until 24) {
drawRoundRect( drawRoundRect(
color = color, color = color,
topLeft = Offset(0f, hour * hourHeightPx + inset), topLeft = Offset(0f, hour * hourHeightPx + top),
size = cellSize, size = cellSize,
cornerRadius = radius, cornerRadius = radius,
) )
@@ -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<TimelineScrollMemory?> { 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
}
@@ -7,6 +7,12 @@ import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -14,7 +20,13 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Rect 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 de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate 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, * 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 * 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. * 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 { sealed interface ViewMorphKey {
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
data class Day(val date: LocalDate) : ViewMorphKey data class Day(val date: LocalDate) : ViewMorphKey
data class Ground(val date: LocalDate) : ViewMorphKey
companion object { companion object {
/** [event]'s occurrence as drawn from [day]. */ /** [event]'s occurrence as drawn from [day]. */
fun event(event: EventInstance, day: LocalDate): Event = fun event(event: EventInstance, day: LocalDate): Event =
@@ -47,8 +64,30 @@ class ViewMorphScope(
val visibility: AnimatedVisibilityScope, val visibility: AnimatedVisibilityScope,
val boundsTransform: BoundsTransform, val boundsTransform: BoundsTransform,
val fadeSpec: FiniteAnimationSpec<Float>, val fadeSpec: FiniteAnimationSpec<Float>,
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<ViewMorphScope?> { null } val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
/** /**
@@ -84,6 +123,7 @@ fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
enter = fadeIn(morph.fadeSpec), enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec), exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform, boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
zIndexInOverlay = 1f, zIndexInOverlay = 1f,
) )
@@ -102,10 +142,64 @@ fun Modifier.morphDay(date: LocalDate): Modifier {
enter = fadeIn(morph.fadeSpec), enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec), exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform, 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<LocalDate>,
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. */ /** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -12,10 +12,12 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import de.jeanlucmakiola.calendula.ui.common.jumpToPage 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.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage
import de.jeanlucmakiola.calendula.ui.common.morphEvent import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.morphPageActive 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.pagedHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute 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.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock 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.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -307,15 +309,10 @@ private fun DayContent(
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
// Shared by every page and the gutter, 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 // survives day-to-day swipes, and carried over from the timeline view
// into the day view (i.e. when arriving from the month/week view). // before this one.
val scrollState = rememberScrollState() val stripHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
LaunchedEffect(Unit) { val scrollState = rememberTimelineScrollState(stripHeight + TIMELINE_TOP_GAP)
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)
}
// Above the pager: a page change mid-drag would strand the floating block // Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page. // inside the outgoing page.
@@ -444,14 +441,15 @@ private fun DayFrame(
// timelines have, so labels and column agree. // timelines have, so labels and column agree.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val zoom = LocalTimelineZoom.current 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, // Start inset so the labels centre on the top bar hamburger,
// matching the week view. // matching the week view.
HourGutter( HourGutter(
scrollState = scrollState, scrollState = scrollState,
hourHeight = hourHeight, hourHeight = hourHeight,
dragController = dragController, 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)) Spacer(Modifier.height(TIMELINE_TOP_GAP))
Timeline( Timeline(
state = state, state = state,
allDayHeight = allDayHeight,
scrollState = scrollState, scrollState = scrollState,
dragController = dragController, dragController = dragController,
active = active, active = active,
@@ -672,6 +671,7 @@ private fun AllDayBar(
@Composable @Composable
private fun Timeline( private fun Timeline(
state: DayUiState.Success, state: DayUiState.Success,
allDayHeight: Dp,
scrollState: ScrollState, scrollState: ScrollState,
dragController: TimelineDragController, dragController: TimelineDragController,
active: Boolean, active: Boolean,
@@ -692,14 +692,27 @@ private fun Timeline(
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // 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 // 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()) { 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 val totalHeight = hourHeight * 24
// The pinch sits above the scroll viewport: it has to outrank the // The pinch sits above the scroll viewport: it has to outrank the
// vertical scroll, and it does that by watching the initial pass, which // vertical scroll, and it does that by watching the initial pass, which
// only reaches it if it is the viewport's ancestor. // 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 // Scrolls on the same state as the gutter and the other pages, so they
// all stay aligned. A static, rounded-clipped window — the content // all stay aligned. A static, rounded-clipped window — the content
// scrolls inside it, so the soft corners are permanent at any scroll // scrolls inside it, so the soft corners are permanent at any scroll
@@ -975,10 +988,11 @@ private fun EventBlock(
private fun DayLoading() { private fun DayLoading() {
val scale = LocalTimelineZoom.current.scale val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val weekHeader = weekDayHeaderHeight()
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column // Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives. // 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 val totalHeight = hourHeight * 24
// The skeleton wears the loaded column's own ground, so the arrival of // 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. // the real day doesn't flash a solid block into a gapped grid.
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent 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.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight import de.jeanlucmakiola.calendula.ui.common.pagedHeight
@@ -2185,6 +2186,7 @@ private fun MonthWeekRow(
.fillMaxHeight() .fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.Cell(d)) .morphElement(MonthMorphKey.Cell(d))
.morphGround(d)
.background( .background(
color = when { color = when {
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
@@ -135,10 +135,12 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec 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.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage
import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent 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.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
import de.jeanlucmakiola.floret.identity.rememberReduceMotion 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.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone 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. */ /** The header's date slot, reserved whether or not it holds today's circle. */
private val DATE_SLOT_SIZE = 28.dp 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). */ /** Total all-day strip height for a week (0 when there are no all-day events). */
internal fun WeekUiState.Success.allDayStripHeight(): Dp { internal fun WeekUiState.Success.allDayStripHeight(): Dp {
if (allDaySpans.isEmpty()) return 0.dp if (allDaySpans.isEmpty()) return 0.dp
@@ -419,16 +429,10 @@ private fun WeekContent(
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
// Shared by every page and the gutter, so the vertical scroll position // Shared by every page and the gutter, so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on // survives week-to-week swipes (e.g. 18:00 stays centred), and carried over
// noon once, on first entry into the week view (i.e. when arriving from the // from the timeline view before this one.
// month/day view), not on every swipe. val stripHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
val scrollState = rememberScrollState() val scrollState = rememberTimelineScrollState(weekDayHeaderHeight() + stripHeight + TIMELINE_TOP_GAP)
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)
}
// Above the pager: a page change mid-drag would strand the floating block // Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page. // inside the outgoing page.
@@ -587,12 +591,13 @@ private fun WeekFrame(
// timelines have, so labels and columns agree. // timelines have, so labels and columns agree.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val zoom = LocalTimelineZoom.current val zoom = LocalTimelineZoom.current
val hourHeight = zoom.scale.hourHeight(maxHeight) val fitHeight = maxHeight + allDayHeight
val hourHeight = zoom.scale.hourHeight(fitHeight)
HourGutter( HourGutter(
scrollState = scrollState, scrollState = scrollState,
hourHeight = hourHeight, hourHeight = hourHeight,
dragController = dragController, 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)) Spacer(Modifier.height(TIMELINE_TOP_GAP))
Timeline( Timeline(
state = state, state = state,
allDayHeight = allDayHeight,
scrollState = scrollState, scrollState = scrollState,
dragController = dragController, dragController = dragController,
active = active, active = active,
@@ -940,6 +946,7 @@ private fun AllDayBar(
@Composable @Composable
private fun Timeline( private fun Timeline(
state: WeekUiState.Success, state: WeekUiState.Success,
allDayHeight: Dp,
scrollState: ScrollState, scrollState: ScrollState,
dragController: TimelineDragController, dragController: TimelineDragController,
active: Boolean, active: Boolean,
@@ -960,14 +967,26 @@ private fun Timeline(
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // 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 // 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()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight) val fitHeight = maxHeight + allDayHeight
val hourHeight = zoom.scale.hourHeight(fitHeight)
val totalHeight = hourHeight * 24 val totalHeight = hourHeight * 24
// The pinch sits above the scroll viewport: it has to outrank the // The pinch sits above the scroll viewport: it has to outrank the
// vertical scroll, and it does that by watching the initial pass, which // vertical scroll, and it does that by watching the initial pass, which
// only reaches it if it is the viewport's ancestor. // 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 // Scrolls on the same state as the gutter and the other pages, so they
// all stay aligned. A static, rounded-clipped window — the content // all stay aligned. A static, rounded-clipped window — the content
// scrolls inside it, so the soft corners are permanent at any scroll // 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 @Composable
private fun WeekLoading(dayCount: Int) { private fun WeekLoading(dayCount: Int) {
val scale = LocalTimelineZoom.current.scale val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val headerHeight = weekDayHeaderHeight()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header skeleton // Header skeleton
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(SKELETON_HEADER_PADDING)) {
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
repeat(dayCount) { repeat(dayCount) {
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = 2.dp) .padding(horizontal = 2.dp)
.height(36.dp) .height(SKELETON_HEADER_HEIGHT)
.background( .background(
MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainer,
RoundedCornerShape(8.dp), RoundedCornerShape(8.dp),
@@ -1291,7 +1314,9 @@ private fun WeekLoading(dayCount: Int) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's // Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives. // 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 val totalHeight = hourHeight * 24
// The skeleton wears the loaded columns' own ground, so the arrival // The skeleton wears the loaded columns' own ground, so the arrival
// of the real week doesn't flash solid blocks into a gapped grid. // of the real week doesn't flash solid blocks into a gapped grid.