Carry the timeline hours between views (#184)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.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<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 }
|
||||
|
||||
/**
|
||||
@@ -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<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. */
|
||||
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user