diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 267ab39..c0b4b43 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -19,6 +20,9 @@ import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.domain.EventForm @@ -26,6 +30,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen +import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.identity.rememberReduceMotion @@ -34,8 +39,10 @@ import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars +import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel +import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform @@ -371,13 +378,23 @@ fun CalendarHost( val reduceMotion = rememberReduceMotion() val morphBounds = rememberViewMorphBoundsTransform() val morphFade = rememberCalendarFadeSpec() + val morphClip = remember { BelowTopBarClip() } + val density = LocalDensity.current + val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) + + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() } + val timelineScroll = remember { TimelineScrollMemory() } CompositionLocalProvider( LocalEventMove provides moveScope, + LocalTimelineScroll provides timelineScroll, // The failure state's way out when every calendar is switched off // (#239); nothing else in the calendar surfaces navigates here. LocalManageCalendars provides { showCalendars = true }, ) { - SharedTransitionLayout(Modifier.fillMaxSize()) { + SharedTransitionLayout( + Modifier + .fillMaxSize() + .onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y }, + ) { AnimatedContent( targetState = view, transitionSpec = { viewSwitch }, @@ -385,8 +402,8 @@ fun CalendarHost( ) { currentView -> val sharedScope = this@SharedTransitionLayout val visibilityScope = this@AnimatedContent - val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade) { - if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade) + val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip) { + if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip) } CompositionLocalProvider(LocalViewMorph provides viewMorph) { when (currentView) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt index 330fb43..82e4e96 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt @@ -57,9 +57,15 @@ internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Fl * drawn across one. Applied to a day column's content, so the cells sit over the * column background but beneath the event blocks — blocks stay in a continuous * coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel - * height; [color] is resolved by the caller from the theme. + * height; [color] is resolved by the caller from the theme. [offsetPx] shifts + * the cells up, for a fixed window over a scrolled column. */ -fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier = +fun Modifier.hourGridCells( + show: Boolean, + hourHeightPx: Float, + color: Color, + offsetPx: () -> Float = { 0f }, +): Modifier = if (!show) { this } else { @@ -71,10 +77,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()), ) val cellSize = Size(size.width, cellHeight) + val top = inset - offsetPx() for (hour in 0 until 24) { drawRoundRect( color = color, - topLeft = Offset(0f, hour * hourHeightPx + inset), + topLeft = Offset(0f, hour * hourHeightPx + top), size = cellSize, cornerRadius = radius, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt new file mode 100644 index 0000000..3694757 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt @@ -0,0 +1,54 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.flow.first +import kotlin.math.roundToInt + +/** + * Where 00:00 sat in the last timeline shown, measured from the top of its + * view's content, so the next timeline view opens on the same hours (#184): an + * event in both keeps its place and only grows sideways. + */ +class TimelineScrollMemory { + var midnightY: Float? = null +} + +val LocalTimelineScroll = staticCompositionLocalOf { null } + +/** + * The scroll state of a timeline whose scrolling area starts [timelineTop] below + * its view's content top. Opens where the last timeline left off, or on noon the + * first time. + */ +@Composable +fun rememberTimelineScrollState(timelineTop: Dp): ScrollState { + val memory = LocalTimelineScroll.current + val topPx = with(LocalDensity.current) { timelineTop.toPx() } + val remembered = memory?.midnightY + val scrollState = rememberScrollState( + initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0, + ) + val currentTop by rememberUpdatedState(topPx) + LaunchedEffect(scrollState) { + if (remembered != null) return@LaunchedEffect + snapshotFlow { scrollState.maxValue }.first { it > 0 } + // Half the scroll range *is* noon: the content spans a full 24 hours, so + // centring the range centres midday at whatever hour height is in force. + scrollState.scrollTo(scrollState.maxValue / 2) + } + if (memory != null) { + LaunchedEffect(scrollState, memory) { + snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it } + } + } + return scrollState +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt index 503cc32..ebad7c3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -7,6 +7,12 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -14,7 +20,13 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.datetime.LocalDate @@ -27,12 +39,17 @@ import kotlinx.datetime.LocalDate * multi-day event is drawn once per day, per week row or per timeline column, * and only one element per key may take part. The segment that starts on the * same day in both views is the one that travels; the rest fade where they are. + * + * A day's ground — its month cell, its timeline column — travels too, so the + * events never arrive ahead of what they sit on. */ sealed interface ViewMorphKey { data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey data class Day(val date: LocalDate) : ViewMorphKey + data class Ground(val date: LocalDate) : ViewMorphKey + companion object { /** [event]'s occurrence as drawn from [day]. */ fun event(event: EventInstance, day: LocalDate): Event = @@ -47,8 +64,30 @@ class ViewMorphScope( val visibility: AnimatedVisibilityScope, val boundsTransform: BoundsTransform, val fadeSpec: FiniteAnimationSpec, + val clip: SharedTransitionScope.OverlayClip, ) +/** + * Keeps what travels below the top bar, which every view has at the same height: + * the overlay draws above the whole screen, so an event scrolled under the bar + * would otherwise cross it. [top] is the bar's bottom edge in the shared layout. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +class BelowTopBarClip : SharedTransitionScope.OverlayClip { + var top = 0f + + override fun getClipPath( + sharedContentState: SharedTransitionScope.SharedContentState, + bounds: Rect, + layoutDirection: LayoutDirection, + density: Density, + ): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) } + + private companion object { + const val FAR = 1e6f + } +} + val LocalViewMorph = compositionLocalOf { null } /** @@ -84,6 +123,7 @@ fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier { enter = fadeIn(morph.fadeSpec), exit = fadeOut(morph.fadeSpec), boundsTransform = morph.boundsTransform, + clipInOverlayDuringTransition = morph.clip, resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, zIndexInOverlay = 1f, ) @@ -102,10 +142,64 @@ fun Modifier.morphDay(date: LocalDate): Modifier { enter = fadeIn(morph.fadeSpec), exit = fadeOut(morph.fadeSpec), boundsTransform = morph.boundsTransform, + clipInOverlayDuringTransition = morph.clip, + // Over the grounds, which a month cell's number sits inside. + zIndexInOverlay = 1f, ) } } +/** Tags a day's ground, under the events and numbers that travel with it. */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun Modifier.morphGround(date: LocalDate): Modifier { + val morph = LocalViewMorph.current ?: return this + return with(morph.shared) { + this@morphGround.sharedBounds( + sharedContentState = rememberSharedContentState(ViewMorphKey.Ground(date)), + animatedVisibilityScope = morph.visibility, + enter = fadeIn(morph.fadeSpec), + exit = fadeOut(morph.fadeSpec), + boundsTransform = morph.boundsTransform, + clipInOverlayDuringTransition = morph.clip, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + ) + } +} + +/** + * The on-screen window of each day's timeline column, painted like the column + * behind the scrolling one: a whole 24-hour column can't travel, its window can. + * Nothing is drawn while there is no morph to take part in. + */ +@Composable +fun TimelineGrounds( + days: List, + scrollState: ScrollState, + hourHeight: Dp, + columnGap: Dp, + modifier: Modifier = Modifier, +) { + if (LocalViewMorph.current == null) return + val showHourGrid = LocalShowHourGrid.current + val hourPx = with(LocalDensity.current) { hourHeight.toPx() } + val cell = MaterialTheme.colorScheme.surfaceContainer + val ground = if (showHourGrid) MaterialTheme.colorScheme.surface else cell + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(columnGap)) { + days.forEach { day -> + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .morphGround(day) + .clipToBounds() + .background(ground) + .hourGridCells(showHourGrid, hourPx, cell) { scrollState.value.toFloat() }, + ) + } + } +} + /** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */ @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 6c817dc..89b0595 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -12,10 +12,12 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateMapOf import de.jeanlucmakiola.calendula.ui.common.jumpToPage +import de.jeanlucmakiola.calendula.ui.common.TimelineGrounds import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage import de.jeanlucmakiola.calendula.ui.common.morphEvent import de.jeanlucmakiola.calendula.ui.common.morphPageActive +import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState import de.jeanlucmakiola.calendula.ui.common.pagedHeight import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute @@ -146,7 +148,7 @@ import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.week.TimedBlock -import kotlinx.coroutines.flow.first +import de.jeanlucmakiola.calendula.ui.week.weekDayHeaderHeight import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -307,15 +309,10 @@ private fun DayContent( val fadeSpec = rememberCalendarFadeSpec() // Shared by every page and the gutter, so the vertical scroll position - // survives day-to-day swipes. We only centre on noon once, on first entry - // into the day view (i.e. when arriving from the month/week view). - val scrollState = rememberScrollState() - LaunchedEffect(Unit) { - snapshotFlow { scrollState.maxValue }.first { it > 0 } - // Half the scroll range *is* noon: the content spans a full 24 hours, so - // centring the range centres midday at whatever hour height is in force. - scrollState.scrollTo(scrollState.maxValue / 2) - } + // survives day-to-day swipes, and carried over from the timeline view + // before this one. + val stripHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp + val scrollState = rememberTimelineScrollState(stripHeight + TIMELINE_TOP_GAP) // Above the pager: a page change mid-drag would strand the floating block // inside the outgoing page. @@ -444,14 +441,15 @@ private fun DayFrame( // timelines have, so labels and column agree. BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val zoom = LocalTimelineZoom.current - val hourHeight = zoom.scale.hourHeight(maxHeight) + val fitHeight = maxHeight + allDayHeight - weekDayHeaderHeight() + val hourHeight = zoom.scale.hourHeight(fitHeight) // Start inset so the labels centre on the top bar hamburger, // matching the week view. HourGutter( scrollState = scrollState, hourHeight = hourHeight, dragController = dragController, - modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom), + modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom), ) } } @@ -521,6 +519,7 @@ private fun DayPage( Spacer(Modifier.height(TIMELINE_TOP_GAP)) Timeline( state = state, + allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, active = active, @@ -672,6 +671,7 @@ private fun AllDayBar( @Composable private fun Timeline( state: DayUiState.Success, + allDayHeight: Dp, scrollState: ScrollState, dragController: TimelineDragController, active: Boolean, @@ -692,14 +692,27 @@ private fun Timeline( // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top - // bar, date header and all-day strip. + // bar and all-day strip. It fits the week view's timeline instead, so an + // hour is as tall here as there (#184). + val weekHeader = weekDayHeaderHeight() BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val hourHeight = zoom.scale.hourHeight(maxHeight) + val fitHeight = maxHeight + allDayHeight - weekHeader + val hourHeight = zoom.scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The pinch sits above the scroll viewport: it has to outrank the // vertical scroll, and it does that by watching the initial pass, which // only reaches it if it is the viewport's ancestor. - val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom) + val pinch = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom) + TimelineGrounds( + days = listOf(state.date), + scrollState = scrollState, + hourHeight = hourHeight, + columnGap = 0.dp, + modifier = Modifier + .fillMaxSize() + .padding(end = TIMELINE_CONTENT_END_INSET) + .clip(RoundedCornerShape(16.dp)), + ) // Scrolls on the same state as the gutter and the other pages, so they // all stay aligned. A static, rounded-clipped window — the content // scrolls inside it, so the soft corners are permanent at any scroll @@ -975,10 +988,11 @@ private fun EventBlock( private fun DayLoading() { val scale = LocalTimelineZoom.current.scale val scrollState = rememberScrollState() + val weekHeader = weekDayHeaderHeight() BoxWithConstraints(modifier = Modifier.fillMaxSize()) { // Same scale resolution as the loaded timeline, so the skeleton's column // doesn't resize the moment the real day arrives. - val hourHeight = scale.hourHeight(maxHeight) + val hourHeight = scale.hourHeight(maxHeight - weekHeader - TIMELINE_TOP_GAP) val totalHeight = hourHeight * 24 // The skeleton wears the loaded column's own ground, so the arrival of // the real day doesn't flash a solid block into a gapped grid. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index cfc7fc8..c31a96f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.morphGround import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.pagedHeight @@ -2185,6 +2186,7 @@ private fun MonthWeekRow( .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) .morphElement(MonthMorphKey.Cell(d)) + .morphGround(d) .background( color = when { inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index b9e9b80..13f9450 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -135,10 +135,12 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.TimelineGrounds import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.floret.identity.rememberReduceMotion @@ -159,7 +161,6 @@ import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -183,6 +184,15 @@ private val HEADER_BOTTOM_PADDING = 8.dp /** The header's date slot, reserved whether or not it holds today's circle. */ private val DATE_SLOT_SIZE = 28.dp +/** The weekday header's height, which the day view takes off to fit the week's hours (#184). */ +@Composable +internal fun weekDayHeaderHeight(): Dp { + val label = MaterialTheme.typography.labelSmall + val line = label.lineHeight.takeIf { it.isSp } ?: label.fontSize + return HEADER_TOP_PADDING + with(LocalDensity.current) { line.toDp() } + 2.dp + + DATE_SLOT_SIZE + HEADER_BOTTOM_PADDING +} + /** Total all-day strip height for a week (0 when there are no all-day events). */ internal fun WeekUiState.Success.allDayStripHeight(): Dp { if (allDaySpans.isEmpty()) return 0.dp @@ -419,16 +429,10 @@ private fun WeekContent( val fadeSpec = rememberCalendarFadeSpec() // Shared by every page and the gutter, so the vertical scroll position - // survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on - // noon once, on first entry into the week view (i.e. when arriving from the - // month/day view), not on every swipe. - val scrollState = rememberScrollState() - LaunchedEffect(Unit) { - snapshotFlow { scrollState.maxValue }.first { it > 0 } - // Half the scroll range *is* noon: the content spans a full 24 hours, so - // centring the range centres midday at whatever hour height is in force. - scrollState.scrollTo(scrollState.maxValue / 2) - } + // survives week-to-week swipes (e.g. 18:00 stays centred), and carried over + // from the timeline view before this one. + val stripHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp + val scrollState = rememberTimelineScrollState(weekDayHeaderHeight() + stripHeight + TIMELINE_TOP_GAP) // Above the pager: a page change mid-drag would strand the floating block // inside the outgoing page. @@ -587,12 +591,13 @@ private fun WeekFrame( // timelines have, so labels and columns agree. BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val zoom = LocalTimelineZoom.current - val hourHeight = zoom.scale.hourHeight(maxHeight) + val fitHeight = maxHeight + allDayHeight + val hourHeight = zoom.scale.hourHeight(fitHeight) HourGutter( scrollState = scrollState, hourHeight = hourHeight, dragController = dragController, - modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom), + modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom), ) } } @@ -670,6 +675,7 @@ private fun WeekPage( Spacer(Modifier.height(TIMELINE_TOP_GAP)) Timeline( state = state, + allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, active = active, @@ -940,6 +946,7 @@ private fun AllDayBar( @Composable private fun Timeline( state: WeekUiState.Success, + allDayHeight: Dp, scrollState: ScrollState, dragController: TimelineDragController, active: Boolean, @@ -960,14 +967,26 @@ private fun Timeline( // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top - // bar, day header and all-day strip. + // bar, day header and all-day strip. The strip is given back, so the scale + // holds while paging and matches the day view's (#184). BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val hourHeight = zoom.scale.hourHeight(maxHeight) + val fitHeight = maxHeight + allDayHeight + val hourHeight = zoom.scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The pinch sits above the scroll viewport: it has to outrank the // vertical scroll, and it does that by watching the initial pass, which // only reaches it if it is the viewport's ancestor. - val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom) + val pinch = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom) + TimelineGrounds( + days = state.days, + scrollState = scrollState, + hourHeight = hourHeight, + columnGap = COLUMN_GAP, + modifier = Modifier + .fillMaxSize() + .padding(end = TIMELINE_CONTENT_END_INSET) + .clip(RoundedCornerShape(16.dp)), + ) // Scrolls on the same state as the gutter and the other pages, so they // all stay aligned. A static, rounded-clipped window — the content // scrolls inside it, so the soft corners are permanent at any scroll @@ -1267,20 +1286,24 @@ private fun EventBlock( } } +private val SKELETON_HEADER_HEIGHT = 36.dp +private val SKELETON_HEADER_PADDING = 8.dp + @Composable private fun WeekLoading(dayCount: Int) { val scale = LocalTimelineZoom.current.scale val scrollState = rememberScrollState() + val headerHeight = weekDayHeaderHeight() Column(modifier = Modifier.fillMaxSize()) { // Header skeleton - Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Row(modifier = Modifier.fillMaxWidth().padding(SKELETON_HEADER_PADDING)) { Spacer(Modifier.width(GUTTER_WIDTH)) repeat(dayCount) { Box( modifier = Modifier .weight(1f) .padding(horizontal = 2.dp) - .height(36.dp) + .height(SKELETON_HEADER_HEIGHT) .background( MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(8.dp), @@ -1291,7 +1314,9 @@ private fun WeekLoading(dayCount: Int) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { // Same scale resolution as the loaded timeline, so the skeleton's // columns don't resize the moment the real week arrives. - val hourHeight = scale.hourHeight(maxHeight) + val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 - + headerHeight - TIMELINE_TOP_GAP + val hourHeight = scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The skeleton wears the loaded columns' own ground, so the arrival // of the real week doesn't flash solid blocks into a gapped grid.