Compare commits

...
2 Commits
Author SHA1 Message Date
Jean-Luc Makiolaandmakiolaj 9f05a53ed2 Page the day view with a pager that follows the finger (#336) (#337)
Moves the day view onto the same pager as the week view (#131): days follow the finger and snap past half a page or on a fling, the hour gutter stays fixed, neighbouring days are loaded ahead. Opening a day from the month grid lands on that date directly.

Also, from review:
- The per-page state cache (week and day) leaked: dropped entries kept their `stateIn` coroutine and last loaded events alive until the ViewModel was cleared. It is now a shared `PageStateCache` in `common/` that gives each entry its own job and cancels it on eviction.
- The week and day anchor `state` chains had lost their `.catch`, so a settings-flow error crashed instead of showing the failure screen.

Part of #336; the month view follows in its own PR.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/337
2026-09-25 20:49:25 +02:00
Jean-Luc Makiola 9ca10fc6ee Page the week view with a pager that follows the finger (#131)
Closes #131
2026-09-25 13:28:03 +02:00
12 changed files with 956 additions and 355 deletions
@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.lerp
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.floor
/**
* One all-day strip height for every page, blended between the two pages a swipe
* sits between so their timelines stay level mid-swipe. It follows the swipe
* directly and only springs when a page's own strip changes at rest. [fallback]
* stands in until the pages have reported theirs, so it doesn't open from zero.
*
* @param heights each composed page's own strip height, by page index.
*/
@Composable
fun pagedStripHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
val currentFallback by rememberUpdatedState(fallback)
val target by remember(pagerState, heights) {
derivedStateOf {
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
val from = floor(position).toInt()
val a = heights[from]
val b = heights[from + 1]
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
}
}
val height = remember { Animatable(target, Dp.VectorConverter) }
LaunchedEffect(height) {
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
if (scrolling) height.snapTo(h) else height.animateTo(h)
}
}
return height.value
}
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
suspend fun PagerState.jumpToPage(
page: Int,
reduceMotion: Boolean,
spec: FiniteAnimationSpec<Float>,
) {
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
}
@@ -40,6 +40,12 @@ import androidx.compose.ui.unit.IntOffset
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/**
* The effects spec from the active motion scheme, for the opacity half of the
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
/**
* One shared state per pager page — a week, a day, a month — so a page and the
* screen's anchor read the same query, and the pages either side stay loaded
* across a swipe.
*
* Every entry shares on its own job, cancelled when [keep] lets it go: a
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
* until the ViewModel itself was cleared. Main thread only.
*
* @param keep whether a cached key is still worth holding once [get] asks for another.
*/
class PageStateCache<K, S>(
private val scope: CoroutineScope,
private val initial: S,
private val keep: (cached: K, requested: K) -> Boolean,
private val load: (K) -> Flow<S>,
) {
private class Entry<S>(val state: StateFlow<S>, val job: Job)
private val entries = HashMap<K, Entry<S>>()
/** The number of entries held, for tests. */
internal val size: Int get() = entries.size
fun get(key: K): StateFlow<S> {
val iterator = entries.iterator()
while (iterator.hasNext()) {
val (cached, entry) = iterator.next()
if (cached != key && !keep(cached, key)) {
entry.job.cancel()
iterator.remove()
}
}
return entries.getOrPut(key) {
val job = SupervisorJob(scope.coroutineContext[Job])
Entry(
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
job = job,
)
}.state
}
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.ui.day
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
/** The day shown on pager [page]. */
internal fun dayForPage(page: Int): LocalDate =
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
/** The pager page showing [date]. */
internal fun dayPageFor(date: LocalDate): Int =
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
@@ -1,7 +1,22 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
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.pagedStripHeight
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -43,12 +58,10 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -109,14 +122,11 @@ import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
@@ -137,7 +147,6 @@ import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.util.Locale
import kotlin.math.roundToInt
@@ -145,6 +154,9 @@ import kotlin.math.roundToInt
private val ALL_DAY_ROW_HEIGHT = 20.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Breathing room between the all-day strip and the scrolling timeline below. */
private val TIMELINE_TOP_GAP = 8.dp
/** Total all-day strip height for the day (0 when there are no all-day events). */
internal fun DayUiState.Success.allDayStripHeight(): Dp {
if (allDay.isEmpty()) return 0.dp
@@ -169,7 +181,7 @@ fun DayScreen(
viewModel: DayViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val date by viewModel.date.collectAsStateWithLifecycle()
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
// When opened from the month grid, anchor to the tapped date.
LaunchedEffect(initialDateIso) {
@@ -179,36 +191,39 @@ fun DayScreen(
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val isOnToday = when (val s = state) {
is DayUiState.Success -> s.date == s.today
else -> true
}
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is DayUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is DayUiState.Success -> if (s.today < s.date) -1 else 1
else -> 0
// Opens on the tapped date directly, rather than on today and then correcting.
val pagerState = rememberPagerState(
initialPage = initialDateIso?.let { dayPageFor(LocalDate.parse(it)) } ?: anchorPage,
) { DAY_PAGE_COUNT }
val pageSpec = rememberCalendarPageSpec()
val reduceMotion = rememberReduceMotion()
// The pager leads and the anchor follows once it settles, so the anchor only
// moves on its own to correct the pager — e.g. re-entry from the month grid
// on another date. Snapped, since that is a correction rather than a move.
LaunchedEffect(anchorPage) {
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
pagerState.scrollToPage(anchorPage)
}
viewModel.goToToday()
}
// Drawer jump-to-date: slide from the side the target lies on.
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
}
// The day under the finger, so the title turns over mid-swipe, not after it.
val date = dayForPage(pagerState.currentPage)
// Off the ticking clock rather than the loaded day, so it turns over at
// midnight; derived, so the per-minute tick doesn't recompose the screen.
val nowState = rememberCurrentMinute()
val zone = remember { TimeZone.currentSystemDefault() }
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
val isOnToday = state !is DayUiState.Success || date == today
// Straight to the pager: a tap mid-fling must still land, and the anchor
// hasn't caught up with the fling yet to tell the difference.
val jumpToDate: (LocalDate) -> Unit = { target ->
slideDir = if (target < date) -1 else 1
viewModel.goToDate(target)
scope.launch { pagerState.jumpToPage(dayPageFor(target), reduceMotion, pageSpec) }
}
val jumpToToday = { jumpToDate(today) }
ModalNavigationDrawer(
drawerState = drawerState,
@@ -240,7 +255,7 @@ fun DayScreen(
topBar = {
DayTopBar(
date = date,
currentYear = currentYear,
currentYear = today.year,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
@@ -262,10 +277,9 @@ fun DayScreen(
) { innerPadding ->
DayContent(
state = state,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
pagerState = pagerState,
day = viewModel::day,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
@@ -279,19 +293,16 @@ fun DayScreen(
@Composable
private fun DayContent(
state: DayUiState,
slideDir: Int,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
pagerState: PagerState,
day: (LocalDate) -> StateFlow<DayUiState>,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-day AnimatedContent 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
// into the day view (i.e. when arriving from the month/week view).
val scrollState = rememberScrollState()
@@ -302,59 +313,45 @@ private fun DayContent(
scrollState.scrollTo(scrollState.maxValue / 2)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// day during a swipe, so the strip slides along but never jumps in height.
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "day-all-day-strip-height",
)
// Whole-page horizontal swipe, one level above the timeline's vertical
// scroll: a horizontal drag crosses this detector's slop, while a vertical
// drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
// Above the AnimatedContent: a page change mid-drag would strand the
// floating block inside the outgoing page.
// Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page.
val dragController = rememberTimelineDragController()
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
val onDrop: (TimelineDrop) -> Unit = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
}
Box(modifier = modifier) {
// Loading and failure concern the provider as a whole, so they stand in
// for the pager; a page still loading its own day handles that itself.
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is DayUiState.Success -> "success-${s.date}"
is DayUiState.Failure -> "failure-${s.reason}"
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
modifier = Modifier.fillMaxSize(),
contentKey = { it::class },
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
label = "day-state",
) { s ->
when (s) {
DayUiState.Loading -> DayLoading()
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
is DayUiState.Success -> DayPager(
pagerState = pagerState,
day = day,
today = s.today,
initialStripHeight = s.allDayStripHeight(),
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
},
onDrop = onDrop,
)
}
}
@@ -362,6 +359,101 @@ private fun DayContent(
}
}
/**
* The days as pages that follow the finger and snap once a swipe passes half a
* page or is flung (#336), beside a gutter that stays put.
*/
@Composable
private fun DayPager(
pagerState: PagerState,
day: (LocalDate) -> StateFlow<DayUiState>,
today: LocalDate,
initialStripHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
// The days either side are composed ahead, so a swipe lands on a
// loaded day rather than watching it fill in.
beyondViewportPageCount = 1,
// A held block moves within its day; the page stays put under it.
userScrollEnabled = !dragController.isDragging,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { page ->
val date = dayForPage(page)
val pageState by remember(date) { day(date) }.collectAsStateWithLifecycle()
val loaded = pageState as? DayUiState.Success
// Until its day arrives a page shows the empty column.
val empty = remember(date, today) {
DayUiState.Success(date = date, today = today, allDay = emptyList(), timed = emptyList())
}
val stripHeight = loaded?.allDayStripHeight()
DisposableEffect(page, stripHeight) {
if (stripHeight != null) stripHeights[page] = stripHeight
onDispose { stripHeights.remove(page) }
}
DayPage(
state = loaded ?: empty,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = page == pagerState.currentPage,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
}
/** What stays put while the days swipe: the hour gutter, with [pages] filling the rest. */
@Composable
private fun DayFrame(
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
pages: @Composable () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) {
Spacer(
Modifier
.fillMaxWidth()
.height(allDayHeight)
.background(MaterialTheme.colorScheme.surface),
)
Spacer(Modifier.height(TIMELINE_TOP_GAP))
// Resolves the hour height off the same viewport height the pages'
// timelines have, so labels and column agree.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val zoom = LocalTimelineZoom.current
val hourHeight = zoom.scale.hourHeight(maxHeight)
// 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),
)
}
}
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
}
}
/** A single day in its frame, without the pager — for the Settings preview. */
@Composable
internal fun DaySuccess(
state: DayUiState.Success,
@@ -371,6 +463,37 @@ internal fun DaySuccess(
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
DayPage(
state = state,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = true,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
/**
* One day's page: all-day strip and column.
*
* @param active whether this is the page on screen, the only one that may
* publish the drag geometry.
*/
@Composable
private fun DayPage(
state: DayUiState.Success,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
@@ -389,13 +512,12 @@ internal fun DaySuccess(
.background(MaterialTheme.colorScheme.surface),
)
}
// Breathing room between the top section and the scrolling timeline
// below.
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(TIMELINE_TOP_GAP))
Timeline(
state = state,
scrollState = scrollState,
dragController = dragController,
active = active,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
@@ -483,8 +605,6 @@ private fun AllDayStrip(
end = TIMELINE_CONTENT_END_INSET,
),
) {
// Keep the gutter-width offset so the bars line up with the day column.
Spacer(Modifier.width(GUTTER_WIDTH))
// Bars are positioned absolutely by lane (vertical stacking); each spans
// the full day-column width. clipToBounds keeps bars from spilling out
// while the height animates.
@@ -547,6 +667,7 @@ private fun Timeline(
state: DayUiState.Success,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
@@ -568,61 +689,53 @@ private fun Timeline(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The pinch sits on the Row, above both scroll viewports: it has to
// outrank the vertical scroll, and it does that by watching the initial
// pass, which only reaches it if it is their ancestor.
// 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)
// Gutter and day column are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position.
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
// Hour gutter (scrolls in sync with the day column). Start inset so the
// labels centre on the top bar hamburger, matching the week view.
HourGutter(
scrollState = scrollState,
// 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
// position.
Box(
modifier = Modifier
.fillMaxSize()
.then(pinch)
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
) {
DayColumnCard(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
)
// Day column: rounded, clipped scroll viewport (permanent corners).
Box(
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
) {
DayColumnCard(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset.
.onGloballyPositioned { coords ->
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
it.isRtl = isRtl
}
},
)
}
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset. Only the page on
// screen publishes it: the ones either side are laid out too.
.onGloballyPositioned { coords ->
if (!active) return@onGloballyPositioned
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
it.isRtl = isRtl
}
},
)
}
}
}
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.CoroutineDispatcher
@@ -18,21 +19,25 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.daysUntil
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
import kotlin.math.abs
/** How far from the requested day [DayViewModel.day] keeps other days cached. */
private const val DAY_CACHE_DAYS = 7
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
@@ -47,32 +52,53 @@ class DayViewModel @Inject constructor(
get() = Clock.System.now().toLocalDateTime(zone).date
private val _date = MutableStateFlow(todayDate)
val date: StateFlow<LocalDate> = _date
/** The pager page the anchor day sits on. */
val anchorPage: StateFlow<Int> = _date
.map { dayPageFor(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = dayPageFor(todayDate),
)
/**
* The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]:
* moving the anchor keeps the last result until the new day arrives, since
* the pages draw their own days and this only gates the failure screen.
*/
val state: StateFlow<DayUiState> = _date
.flatMapLatest { day ->
val range = dayRange(day, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(day, calendars, instances)
}
}
.flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } }
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayUiState.Loading,
)
fun goToPrev() {
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
private val days = PageStateCache<LocalDate, DayUiState>(
scope = viewModelScope,
initial = DayUiState.Loading,
// Far enough to cover the pages either side and a swipe back, so paging
// through a month doesn't keep a month of queries around.
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS },
) { date ->
combine(
repository.calendars(),
repository.instances(dayRange(date, zone)),
) { calendars, instances ->
buildState(date, calendars, instances)
}
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
}
fun goToNext() {
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
/** The day [date], shared between its pager page and [state]. Main thread only. */
fun day(date: LocalDate): StateFlow<DayUiState> = days.get(date)
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
if (dayPageFor(_date.value) != page) _date.value = dayForPage(page)
}
fun goToToday() {
@@ -0,0 +1,25 @@
package de.jeanlucmakiola.calendula.ui.week
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val WEEK_PAGE_COUNT: Int = 20_000
private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2
/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 3)
/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */
internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate =
PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY)
/** The pager page whose week contains [date], with weeks starting on [firstDay]. */
internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
}
@@ -1,7 +1,21 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateMapOf
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.datetime.DayOfWeek
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -48,12 +62,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.key
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -69,6 +81,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -122,9 +135,7 @@ import de.jeanlucmakiola.calendula.ui.common.trimmedLines
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.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -142,7 +153,6 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
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.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.flow.first
@@ -152,7 +162,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
@@ -162,6 +171,14 @@ private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Gap between day columns; part of the column pitch a drag maps positions through. */
private val COLUMN_GAP = 2.dp
/** Breathing room between the header block and the scrolling timeline below. */
private val TIMELINE_TOP_GAP = 8.dp
private val HEADER_TOP_PADDING = 4.dp
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
/** 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
@@ -186,7 +203,8 @@ fun WeekScreen(
viewModel: WeekViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
val firstDay by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
@@ -199,39 +217,41 @@ fun WeekScreen(
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val isOnCurrentWeek = when (val s = state) {
// True when today falls inside the displayed week — independent of which
// weekday the user picked as the first day.
is WeekUiState.Success ->
s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
else -> true
}
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is WeekUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
else -> 0
val pagerState = rememberPagerState(initialPage = anchorPage) { WEEK_PAGE_COUNT }
val pageSpec = rememberCalendarPageSpec()
val reduceMotion = rememberReduceMotion()
// The pager leads and the anchor follows once it settles, so the only thing
// left moving the anchor on its own is the first day re-framing the week —
// including the stored preference arriving after the pager opened on the
// Monday default. Snapped, since that is a correction rather than a move.
LaunchedEffect(anchorPage) {
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
pagerState.scrollToPage(anchorPage)
}
viewModel.goToToday()
}
// Drawer jump-to-date: slide from the side the target week lies on.
// The page it opened on is skipped: a first-day change landing in between
// would read it as a different week.
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
}
// The week under the finger, so the title turns over mid-swipe, not after it.
val weekStart = weekStartForPage(pagerState.currentPage, firstDay)
// Off the ticking clock rather than the loaded week, so it turns over at
// midnight; derived, so the per-minute tick doesn't recompose the screen.
val zone = remember { TimeZone.currentSystemDefault() }
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
// Independent of which weekday the user picked as the first day.
val isOnCurrentWeek = state !is WeekUiState.Success ||
(today >= weekStart && today <= weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY))
// Straight to the pager: a tap mid-fling must still land, and the anchor
// hasn't caught up with the fling yet to tell the difference.
val jumpToDate: (LocalDate) -> Unit = { target ->
slideDir = if (target < weekStart) -1 else 1
viewModel.goToDate(target)
val page = weekPageFor(target, firstDay)
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
}
val jumpToToday = { jumpToDate(today) }
ModalNavigationDrawer(
drawerState = drawerState,
@@ -263,7 +283,7 @@ fun WeekScreen(
topBar = {
WeekTopBar(
weekStart = weekStart,
currentYear = currentYear,
currentYear = today.year,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
@@ -281,8 +301,6 @@ fun WeekScreen(
onToday = jumpToToday,
onCreate = {
// Anchor on today when it's in view, else the week's first day.
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
},
)
@@ -291,10 +309,10 @@ fun WeekScreen(
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
WeekContent(
state = state,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
pagerState = pagerState,
firstDay = firstDay,
week = viewModel::week,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
@@ -310,20 +328,18 @@ fun WeekScreen(
@Composable
private fun WeekContent(
state: WeekUiState,
slideDir: Int,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
pagerState: PagerState,
firstDay: DayOfWeek,
week: (LocalDate) -> StateFlow<WeekUiState>,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-week AnimatedContent 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
// noon once, on first entry into the week view (i.e. when arriving from the
// month/day view), not on every swipe.
@@ -335,62 +351,47 @@ private fun WeekContent(
scrollState.scrollTo(scrollState.maxValue / 2)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// week during a swipe, so the strip slides along but never jumps in height;
// it just springs smoothly from the old to the new size.
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "all-day-strip-height",
)
// Whole-page horizontal swipe. It sits one level above the timeline's
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
// while a vertical drag is consumed by the inner scroll first — so the two
// gestures coexist without fighting.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
// Above the AnimatedContent: a page change mid-drag would strand the
// floating block inside the outgoing page.
// Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page.
val dragController = rememberTimelineDragController()
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
val onDrop: (TimelineDrop) -> Unit = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
}
Box(modifier = modifier) {
// Loading and failure concern the provider as a whole, so they stand in
// for the pager; a page still loading its own week handles that itself.
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is WeekUiState.Success -> "success-${s.weekStart}"
is WeekUiState.Failure -> "failure-${s.reason}"
WeekUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "week-transition",
modifier = Modifier.fillMaxSize(),
contentKey = { it::class },
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
label = "week-state",
) { s ->
when (s) {
WeekUiState.Loading -> WeekLoading()
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekSuccess(
state = s,
is WeekUiState.Success -> WeekPager(
pagerState = pagerState,
firstDay = firstDay,
week = week,
today = s.today,
initialStripHeight = s.allDayStripHeight(),
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = { drop ->
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
},
onDrop = onDrop,
)
}
}
@@ -398,6 +399,123 @@ private fun WeekContent(
}
}
/**
* The weeks as pages that follow the finger and snap once a swipe passes half a
* page or is flung (#131), beside a gutter that stays put.
*/
@Composable
private fun WeekPager(
pagerState: PagerState,
firstDay: DayOfWeek,
week: (LocalDate) -> StateFlow<WeekUiState>,
today: LocalDate,
initialStripHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
WeekFrame(
weekStart = weekStartForPage(pagerState.currentPage, firstDay),
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
// The weeks either side are composed ahead, so a swipe lands on a
// loaded week rather than watching it fill in.
beyondViewportPageCount = 1,
// A held block moves within its week; the page stays put under it.
userScrollEnabled = !dragController.isDragging,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = rememberCalendarPageSpec(),
),
) { page ->
val start = weekStartForPage(page, firstDay)
val pageState by remember(start) { week(start) }.collectAsStateWithLifecycle()
val loaded = pageState as? WeekUiState.Success
// Until its week arrives a page shows the week's frame without events.
val empty = remember(start, today) { emptyWeek(start, today) }
val stripHeight = loaded?.allDayStripHeight()
DisposableEffect(page, stripHeight) {
if (stripHeight != null) stripHeights[page] = stripHeight
onDispose { stripHeights.remove(page) }
}
WeekPage(
state = loaded ?: empty,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = page == pagerState.currentPage,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
}
/** A week with no events yet, for a page whose week is still loading. */
private fun emptyWeek(start: LocalDate, today: LocalDate): WeekUiState.Success {
val days = (0 until 7).map { start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
return WeekUiState.Success(
weekStart = start,
today = today,
days = days,
allDaySpans = emptyList(),
timedByDay = emptyMap(),
)
}
/**
* What stays put while the weeks swipe: the week-number badge and the hour
* gutter, with [pages] filling the rest.
*/
@Composable
private fun WeekFrame(
weekStart: LocalDate,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
pages: @Composable () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
) {
WeekNumberCell(weekStart)
Spacer(Modifier.height(allDayHeight))
}
Spacer(Modifier.height(TIMELINE_TOP_GAP))
// Resolves the hour height off the same viewport height the pages'
// timelines have, so labels and columns agree.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val zoom = LocalTimelineZoom.current
val hourHeight = zoom.scale.hourHeight(maxHeight)
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
)
}
}
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
}
}
/** A single week in its frame, without the pager — for the Settings preview. */
@Composable
internal fun WeekSuccess(
state: WeekUiState.Success,
@@ -408,6 +526,44 @@ internal fun WeekSuccess(
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
WeekFrame(
weekStart = state.weekStart,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
) {
WeekPage(
state = state,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
active = true,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
/**
* One week's page: header, all-day strip and day columns.
*
* @param active whether this is the page on screen, the only one that may
* publish the drag geometry.
*/
@Composable
private fun WeekPage(
state: WeekUiState.Success,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -424,13 +580,12 @@ internal fun WeekSuccess(
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
}
// Breathing room between the top section and the scrolling timeline
// below.
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(TIMELINE_TOP_GAP))
Timeline(
state = state,
scrollState = scrollState,
dragController = dragController,
active = active,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
@@ -505,24 +660,11 @@ private fun WeekDayHeader(
onOpenDay: (LocalDate) -> Unit,
) {
val locale = currentLocale()
val weekStart = days.first()
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
) {
// Mirror the day-column layout (empty weekday line + spacer) so the
// badge lines up vertically with the date numbers. The start inset centres
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
Column(
modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = " ", style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.height(2.dp))
WeekNumberBadge(weekNumber = weekNumber)
}
days.forEach { date ->
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val isToday = date == today
@@ -533,16 +675,19 @@ private fun WeekDayHeader(
.clickable { onOpenDay(date) },
horizontalAlignment = Alignment.CenterHorizontally,
) {
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Clip,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
// Always reserve the 28dp circle slot so the header height is
// Always reserve the circle slot so the header height is
// identical whether or not the week contains today.
Box(
modifier = Modifier.size(28.dp),
modifier = Modifier.size(DATE_SLOT_SIZE),
contentAlignment = Alignment.Center,
) {
if (isToday) {
@@ -572,6 +717,33 @@ private fun WeekDayHeader(
}
}
/**
* The header's gutter cell, laid out like a day's (empty weekday line, spacer,
* date slot) so the badge lines up with the date numbers and the cell is exactly
* as tall as the header beside it. The start inset centres the badge on the top
* bar's hamburger (see GUTTER_CONTENT_START_INSET).
*/
@Composable
private fun WeekNumberCell(weekStart: LocalDate) {
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
start = GUTTER_CONTENT_START_INSET,
top = HEADER_TOP_PADDING,
bottom = HEADER_BOTTOM_PADDING,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = " ", style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.height(2.dp))
Box(modifier = Modifier.height(DATE_SLOT_SIZE), contentAlignment = Alignment.TopCenter) {
WeekNumberBadge(weekNumber = weekNumber)
}
}
}
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
* filled box and bold number — at the month grid's size, so the two agree (#213). */
@Composable
@@ -612,8 +784,6 @@ private fun AllDayStrip(
end = TIMELINE_CONTENT_END_INSET,
),
) {
// Keep the gutter-width offset so the bars line up with the day columns.
Spacer(Modifier.width(GUTTER_WIDTH))
// Span bars are positioned absolutely so a multi-day event is one
// connected bar across columns rather than a chip per day. clipToBounds
// keeps bars from spilling out while the height animates.
@@ -682,6 +852,7 @@ private fun Timeline(
state: WeekUiState.Success,
scrollState: ScrollState,
dragController: TimelineDragController,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
@@ -703,70 +874,61 @@ private fun Timeline(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The pinch sits on the Row, above both scroll viewports: it has to
// outrank the vertical scroll, and it does that by watching the initial
// pass, which only reaches it if it is their ancestor.
// 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)
// Gutter and day columns are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position (not just at the
// day's start/end).
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
// Hour gutter (scrolls in sync with the day columns). Same start inset
// as the header badge so the labels sit under it and on the hamburger.
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
)
// Day columns: rounded, clipped scroll viewport (permanent corners).
Box(
// 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
// position (not just at the day's start/end).
Box(
modifier = Modifier
.fillMaxSize()
.then(pinch)
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
) {
Row(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset. Only the page on
// screen publishes it: the ones either side are laid out too.
.onGloballyPositioned { coords ->
if (!active) return@onGloballyPositioned
val gap = with(density) { COLUMN_GAP.toPx() }
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = gap
it.columnWidthPx = (coords.size.width + gap) / state.days.size
it.days = state.days
it.isRtl = isRtl
}
},
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset.
.onGloballyPositioned { coords ->
val gap = with(density) { COLUMN_GAP.toPx() }
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = gap
it.columnWidthPx = (coords.size.width + gap) / state.days.size
it.days = state.days
it.isRtl = isRtl
}
},
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
) {
state.days.forEach { day ->
DayColumnCard(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
state.days.forEach { day ->
DayColumnCard(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
}
}
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -30,6 +32,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
@@ -38,9 +41,13 @@ import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
import kotlin.math.abs
const val MINUTES_PER_DAY: Int = 24 * 60
/** How far from the requested week [WeekViewModel.week] keeps other weeks cached. */
private const val WEEK_CACHE_DAYS = 28
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class WeekViewModel @Inject constructor(
@@ -64,7 +71,7 @@ class WeekViewModel @Inject constructor(
get() = Clock.System.now().toLocalDateTime(zone).date
/** First day of the week, from the Settings preference (AUTO → locale). */
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.weekStart
.map { it.resolveFirstDay(locale) }
.stateIn(
scope = viewModelScope,
@@ -73,55 +80,65 @@ class WeekViewModel @Inject constructor(
)
// Anchor is a representative day inside the visible week; the actual week
// start is derived against [weekStart], so changing the first-day preference
// re-frames the same week instead of jumping.
// start is derived against [firstDayOfWeek], so changing the first-day
// preference re-frames the same week instead of jumping.
private val _anchor = MutableStateFlow(todayDate)
val weekStartDate: StateFlow<LocalDate> =
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
/** The pager page the anchor's week sits on. */
val anchorPage: StateFlow<Int> =
combine(_anchor, firstDayOfWeek) { anchor, ws -> weekPageFor(anchor, ws) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
initialValue = weekPageFor(todayDate, DayOfWeek.MONDAY),
)
/**
* The anchor week's state. Once loaded it never falls back to [WeekUiState.Loading]:
* moving the anchor keeps the last result until the new week arrives, since
* the pages draw their own weeks and this only gates the failure screen.
*/
val state: StateFlow<WeekUiState> =
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
combine(_anchor, firstDayOfWeek) { anchor, ws -> anchor.startOfWeek(ws) }
.distinctUntilChanged()
.flatMapLatest { start ->
val range = weekRange(start, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(start, calendars, instances)
}
}
.flatMapLatest { start -> week(start).filterNot { it is WeekUiState.Loading } }
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = WeekUiState.Loading,
)
fun goToPrev() {
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
private val weeks = PageStateCache<LocalDate, WeekUiState>(
scope = viewModelScope,
initial = WeekUiState.Loading,
// Far enough to cover the pages either side and a swipe back, so paging
// through a year doesn't keep a year of queries around.
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= WEEK_CACHE_DAYS },
) { start ->
combine(
repository.calendars(),
repository.instances(weekRange(start, zone)),
) { calendars, instances ->
buildState(start, calendars, instances)
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
}
fun goToNext() {
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
/** The week starting at [start], shared between its pager page and [state]. Main thread only. */
fun week(start: LocalDate): StateFlow<WeekUiState> = weeks.get(start)
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
val ws = firstDayOfWeek.value
if (weekPageFor(_anchor.value, ws) != page) _anchor.value = weekStartForPage(page, ws)
}
fun goToToday() {
_anchor.value = todayDate
}
/** Jump to the week containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_anchor.value = date
}
private fun buildState(
start: LocalDate,
calendars: List<CalendarSource>,
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.math.abs
@OptIn(ExperimentalCoroutinesApi::class)
class PageStateCacheTest {
@Test
fun `the same key shares one state`() = runTest {
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { flow { emit(it) } }
assertThat(cache.get(5)).isSameInstanceAs(cache.get(5))
}
@Test
fun `a dropped entry stops loading`() = runTest(UnconfinedTestDispatcher()) {
var running = 0
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { key ->
flow {
running++
try {
emit(key)
awaitCancellation()
} finally {
running--
}
}
}
backgroundScope.launch { cache.get(0).collect {} }
assertThat(running).isEqualTo(1)
// Far enough away that 0 is let go, even though it is still collected.
cache.get(10)
assertThat(running).isEqualTo(0)
assertThat(cache.size).isEqualTo(1)
}
@Test
fun `nearby entries are kept`() = runTest {
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 2 }) { flow { emit(it) } }
cache.get(4)
cache.get(5)
cache.get(6)
assertThat(cache.size).isEqualTo(3)
}
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.ui.day
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class DayPagingTest {
@Test
fun `a page and its day round-trip`() {
listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day ->
assertThat(dayForPage(dayPageFor(day))).isEqualTo(day)
}
}
@Test
fun `adjacent pages are adjacent days across a month end`() {
val page = dayPageFor(LocalDate(2026, 2, 28))
assertThat(dayForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1))
assertThat(dayForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27))
}
}
@@ -0,0 +1,47 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import org.junit.jupiter.api.Test
class WeekPagingTest {
// 2026-09-27 is a Sunday.
private val sunday = LocalDate(2026, 9, 27)
@Test
fun `a page and its week round-trip for every first day`() {
DayOfWeek.entries.forEach { firstDay ->
val page = weekPageFor(sunday, firstDay)
val start = weekStartForPage(page, firstDay)
assertThat(start).isEqualTo(sunday.startOfWeek(firstDay))
assertThat(weekPageFor(start, firstDay)).isEqualTo(page)
}
}
@Test
fun `every day of a week maps to the same page`() {
val start = sunday.startOfWeek(DayOfWeek.MONDAY)
val pages = (0..6).map { weekPageFor(start.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
assertThat(pages.toSet()).hasSize(1)
}
@Test
fun `adjacent pages are adjacent weeks`() {
val page = weekPageFor(sunday, DayOfWeek.SUNDAY)
assertThat(weekStartForPage(page + 1, DayOfWeek.SUNDAY))
.isEqualTo(LocalDate(2026, 10, 4))
assertThat(weekStartForPage(page - 1, DayOfWeek.SUNDAY))
.isEqualTo(LocalDate(2026, 9, 20))
}
@Test
fun `dates before the epoch page resolve too`() {
val old = LocalDate(1990, 5, 16)
val page = weekPageFor(old, DayOfWeek.MONDAY)
assertThat(weekStartForPage(page, DayOfWeek.MONDAY)).isEqualTo(LocalDate(1990, 5, 14))
}
}