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
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -43,12 +58,10 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.rememberTimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
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.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||||
@@ -137,7 +147,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -145,6 +154,9 @@ import kotlin.math.roundToInt
|
|||||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.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). */
|
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||||
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||||
if (allDay.isEmpty()) return 0.dp
|
if (allDay.isEmpty()) return 0.dp
|
||||||
@@ -169,7 +181,7 @@ fun DayScreen(
|
|||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
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.
|
// When opened from the month grid, anchor to the tapped date.
|
||||||
LaunchedEffect(initialDateIso) {
|
LaunchedEffect(initialDateIso) {
|
||||||
@@ -179,36 +191,39 @@ fun DayScreen(
|
|||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val isOnToday = when (val s = state) {
|
// Opens on the tapped date directly, rather than on today and then correcting.
|
||||||
is DayUiState.Success -> s.date == s.today
|
val pagerState = rememberPagerState(
|
||||||
else -> true
|
initialPage = initialDateIso?.let { dayPageFor(LocalDate.parse(it)) } ?: anchorPage,
|
||||||
}
|
) { DAY_PAGE_COUNT }
|
||||||
|
val pageSpec = rememberCalendarPageSpec()
|
||||||
// Drives whether the title carries the year. Falls back to the clock only
|
val reduceMotion = rememberReduceMotion()
|
||||||
// while the first load is in flight, when there is no state to read today from.
|
// The pager leads and the anchor follows once it settles, so the anchor only
|
||||||
val currentYear = when (val s = state) {
|
// moves on its own to correct the pager — e.g. re-entry from the month grid
|
||||||
is DayUiState.Success -> s.today.year
|
// on another date. Snapped, since that is a correction rather than a move.
|
||||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
LaunchedEffect(anchorPage) {
|
||||||
}
|
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
|
||||||
|
pagerState.scrollToPage(anchorPage)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
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 ->
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
slideDir = if (target < date) -1 else 1
|
scope.launch { pagerState.jumpToPage(dayPageFor(target), reduceMotion, pageSpec) }
|
||||||
viewModel.goToDate(target)
|
|
||||||
}
|
}
|
||||||
|
val jumpToToday = { jumpToDate(today) }
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -240,7 +255,7 @@ fun DayScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
currentYear = currentYear,
|
currentYear = today.year,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
@@ -262,10 +277,9 @@ fun DayScreen(
|
|||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
DayContent(
|
DayContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
pagerState = pagerState,
|
||||||
onSwipeNext = goNext,
|
day = viewModel::day,
|
||||||
onSwipePrev = goPrev,
|
onRetry = viewModel::goToToday,
|
||||||
onRetry = jumpToToday,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -279,19 +293,16 @@ fun DayScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DayContent(
|
private fun DayContent(
|
||||||
state: DayUiState,
|
state: DayUiState,
|
||||||
slideDir: Int,
|
pagerState: PagerState,
|
||||||
onSwipeNext: () -> Unit,
|
day: (LocalDate) -> StateFlow<DayUiState>,
|
||||||
onSwipePrev: () -> Unit,
|
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
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
|
// 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).
|
// into the day view (i.e. when arriving from the month/week view).
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
@@ -302,59 +313,45 @@ private fun DayContent(
|
|||||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
// Above the pager: a page change mid-drag would strand the floating block
|
||||||
// day during a swipe, so the strip slides along but never jumps in height.
|
// inside the outgoing page.
|
||||||
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.
|
|
||||||
val dragController = rememberTimelineDragController()
|
val dragController = rememberTimelineDragController()
|
||||||
val move = LocalEventMove.current
|
val move = LocalEventMove.current
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
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) {
|
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(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentKey = { s ->
|
contentKey = { it::class },
|
||||||
when (s) {
|
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
|
||||||
is DayUiState.Success -> "success-${s.date}"
|
label = "day-state",
|
||||||
is DayUiState.Failure -> "failure-${s.reason}"
|
|
||||||
DayUiState.Loading -> "loading"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
|
||||||
label = "day-transition",
|
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
DayUiState.Loading -> DayLoading()
|
DayUiState.Loading -> DayLoading()
|
||||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is DayUiState.Success -> DaySuccess(
|
is DayUiState.Success -> DayPager(
|
||||||
state = s,
|
pagerState = pagerState,
|
||||||
|
day = day,
|
||||||
|
today = s.today,
|
||||||
|
initialStripHeight = s.allDayStripHeight(),
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = { drop ->
|
onDrop = onDrop,
|
||||||
move?.move(
|
|
||||||
MoveRequest(
|
|
||||||
eventId = drop.event.eventId,
|
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
|
||||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
|
||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
@Composable
|
||||||
internal fun DaySuccess(
|
internal fun DaySuccess(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
@@ -371,6 +463,37 @@ internal fun DaySuccess(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> 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()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// 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),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Breathing room between the top section and the scrolling timeline
|
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||||
// below.
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
|
active = active,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
@@ -483,8 +605,6 @@ private fun AllDayStrip(
|
|||||||
end = TIMELINE_CONTENT_END_INSET,
|
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
|
// Bars are positioned absolutely by lane (vertical stacking); each spans
|
||||||
// the full day-column width. clipToBounds keeps bars from spilling out
|
// the full day-column width. clipToBounds keeps bars from spilling out
|
||||||
// while the height animates.
|
// while the height animates.
|
||||||
@@ -547,6 +667,7 @@ private fun Timeline(
|
|||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
|
active: Boolean,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
@@ -568,61 +689,53 @@ private fun Timeline(
|
|||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||||
val totalHeight = hourHeight * 24
|
val totalHeight = hourHeight * 24
|
||||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
// The pinch sits above the scroll viewport: it has to outrank the
|
||||||
// outrank the vertical scroll, and it does that by watching the initial
|
// vertical scroll, and it does that by watching the initial pass, which
|
||||||
// pass, which only reaches it if it is their ancestor.
|
// only reaches it if it is the viewport's ancestor.
|
||||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
// Scrolls on the same state as the gutter and the other pages, so they
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// all stay aligned. A static, rounded-clipped window — the content
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// scrolls inside it, so the soft corners are permanent at any scroll
|
||||||
// soft corners are permanent at any scroll position.
|
// position.
|
||||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
Box(
|
||||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
modifier = Modifier
|
||||||
// labels centre on the top bar hamburger, matching the week view.
|
.fillMaxSize()
|
||||||
HourGutter(
|
.then(pinch)
|
||||||
scrollState = scrollState,
|
.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,
|
hourHeight = hourHeight,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
)
|
onEventClick = onEventClick,
|
||||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
onCreateAt = onCreateAt,
|
||||||
Box(
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.fillMaxHeight()
|
.height(totalHeight)
|
||||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
// The scrolling content itself, so its root position
|
||||||
.clip(RoundedCornerShape(16.dp))
|
// already folds in the scroll offset. Only the page on
|
||||||
.verticalScroll(scrollState)
|
// screen publishes it: the ones either side are laid out too.
|
||||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
.onGloballyPositioned { coords ->
|
||||||
) {
|
if (!active) return@onGloballyPositioned
|
||||||
DayColumnCard(
|
dragController.geometry.let {
|
||||||
blocks = state.timed,
|
it.grid = coords
|
||||||
dark = dark,
|
it.scroll = scrollState
|
||||||
date = state.date,
|
it.hourPx = with(density) { hourHeight.toPx() }
|
||||||
today = state.today,
|
it.blockInsetPx = blockInsetPx
|
||||||
hourHeight = hourHeight,
|
it.columnGapPx = 0f
|
||||||
dragController = dragController,
|
it.columnWidthPx = coords.size.width.toFloat()
|
||||||
onEventClick = onEventClick,
|
it.days = listOf(state.date)
|
||||||
onCreateAt = onCreateAt,
|
it.isRtl = isRtl
|
||||||
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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
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.layoutAllDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -18,21 +19,25 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filterNot
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -47,32 +52,53 @@ class DayViewModel @Inject constructor(
|
|||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
private val _date = MutableStateFlow(todayDate)
|
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
|
val state: StateFlow<DayUiState> = _date
|
||||||
.flatMapLatest { day ->
|
.flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } }
|
||||||
val range = dayRange(day, zone)
|
|
||||||
combine(
|
|
||||||
repository.calendars(),
|
|
||||||
repository.instances(range),
|
|
||||||
) { calendars, instances ->
|
|
||||||
buildState(day, calendars, instances)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = DayUiState.Loading,
|
initialValue = DayUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun goToPrev() {
|
private val days = PageStateCache<LocalDate, DayUiState>(
|
||||||
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
|
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() {
|
/** The day [date], shared between its pager page and [state]. Main thread only. */
|
||||||
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
|
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() {
|
fun goToToday() {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.VectorConverter
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@@ -12,13 +10,12 @@ import androidx.compose.foundation.pager.PagerState
|
|||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.ui.unit.lerp
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
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.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlin.math.floor
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -252,13 +249,7 @@ fun WeekScreen(
|
|||||||
// hasn't caught up with the fling yet to tell the difference.
|
// hasn't caught up with the fling yet to tell the difference.
|
||||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
val page = weekPageFor(target, firstDay)
|
val page = weekPageFor(target, firstDay)
|
||||||
scope.launch {
|
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
|
||||||
if (reduceMotion) {
|
|
||||||
pagerState.scrollToPage(page)
|
|
||||||
} else {
|
|
||||||
pagerState.animateScrollToPage(page, animationSpec = pageSpec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val jumpToToday = { jumpToDate(today) }
|
val jumpToToday = { jumpToDate(today) }
|
||||||
|
|
||||||
@@ -472,33 +463,6 @@ private fun WeekPager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One all-day strip height for every page, blended between the two weeks a swipe
|
|
||||||
* sits between so their timelines stay level mid-swipe. It follows the swipe
|
|
||||||
* directly and only springs when a week's own strip changes at rest. [fallback]
|
|
||||||
* stands in until the pages have reported theirs, so it doesn't open from zero.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private 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 week with no events yet, for a page whose week is still loading. */
|
/** A week with no events yet, for a page whose week is still loading. */
|
||||||
private fun emptyWeek(start: LocalDate, today: LocalDate): WeekUiState.Success {
|
private fun emptyWeek(start: LocalDate, today: LocalDate): WeekUiState.Success {
|
||||||
val days = (0 until 7).map { start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
|
val days = (0 until 7).map { start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -101,38 +102,33 @@ class WeekViewModel @Inject constructor(
|
|||||||
combine(_anchor, firstDayOfWeek) { anchor, ws -> anchor.startOfWeek(ws) }
|
combine(_anchor, firstDayOfWeek) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.flatMapLatest { start -> week(start).filterNot { it is WeekUiState.Loading } }
|
.flatMapLatest { start -> week(start).filterNot { it is WeekUiState.Loading } }
|
||||||
|
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = WeekUiState.Loading,
|
initialValue = WeekUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val weeks = HashMap<LocalDate, StateFlow<WeekUiState>>()
|
private val weeks = PageStateCache<LocalDate, WeekUiState>(
|
||||||
|
scope = viewModelScope,
|
||||||
/**
|
initial = WeekUiState.Loading,
|
||||||
* The week starting at [start], shared between its pager page and [state].
|
// Far enough to cover the pages either side and a swipe back, so paging
|
||||||
* Weeks far from the one asked for are dropped, so paging through a year
|
// through a year doesn't keep a year of queries around.
|
||||||
* doesn't keep a year of queries around. Main thread only.
|
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= WEEK_CACHE_DAYS },
|
||||||
*/
|
) { start ->
|
||||||
fun week(start: LocalDate): StateFlow<WeekUiState> {
|
combine(
|
||||||
weeks.keys.removeAll { abs(it.daysUntil(start)) > WEEK_CACHE_DAYS }
|
repository.calendars(),
|
||||||
return weeks.getOrPut(start) {
|
repository.instances(weekRange(start, zone)),
|
||||||
combine(
|
) { calendars, instances ->
|
||||||
repository.calendars(),
|
buildState(start, calendars, instances)
|
||||||
repository.instances(weekRange(start, zone)),
|
|
||||||
) { calendars, instances ->
|
|
||||||
buildState(start, calendars, instances)
|
|
||||||
}
|
|
||||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = WeekUiState.Loading,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||||
fun onPageSettled(page: Int) {
|
fun onPageSettled(page: Int) {
|
||||||
val ws = firstDayOfWeek.value
|
val ws = firstDayOfWeek.value
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user