Compare commits
2
Commits
main
...
release/v2.22.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f05a53ed2 | ||
|
|
9ca10fc6ee |
@@ -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> =
|
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
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
|
* The effects spec from the active motion scheme, for the opacity half of the
|
||||||
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
* 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
|
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() {
|
||||||
|
|||||||
@@ -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
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
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.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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -48,12 +62,10 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
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.alpha
|
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.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
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.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.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.calendula.ui.common.withTitleWeight
|
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
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.hourCellBlockInset
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
||||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -152,7 +162,6 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
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. */
|
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||||
private val COLUMN_GAP = 2.dp
|
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). */
|
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||||
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||||
if (allDaySpans.isEmpty()) return 0.dp
|
if (allDaySpans.isEmpty()) return 0.dp
|
||||||
@@ -186,7 +203,8 @@ fun WeekScreen(
|
|||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
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()
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
// The instant before which an event counts as completed, or null when dimming
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
@@ -199,39 +217,41 @@ fun WeekScreen(
|
|||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val isOnCurrentWeek = when (val s = state) {
|
val pagerState = rememberPagerState(initialPage = anchorPage) { WEEK_PAGE_COUNT }
|
||||||
// True when today falls inside the displayed week — independent of which
|
val pageSpec = rememberCalendarPageSpec()
|
||||||
// weekday the user picked as the first day.
|
val reduceMotion = rememberReduceMotion()
|
||||||
is WeekUiState.Success ->
|
// The pager leads and the anchor follows once it settles, so the only thing
|
||||||
s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
// left moving the anchor on its own is the first day re-framing the week —
|
||||||
else -> true
|
// 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) {
|
||||||
// Drives whether the title carries the year. Falls back to the clock only
|
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
|
||||||
// while the first load is in flight, when there is no state to read today from.
|
pagerState.scrollToPage(anchorPage)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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 ->
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
slideDir = if (target < weekStart) -1 else 1
|
val page = weekPageFor(target, firstDay)
|
||||||
viewModel.goToDate(target)
|
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
|
||||||
}
|
}
|
||||||
|
val jumpToToday = { jumpToDate(today) }
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -263,7 +283,7 @@ fun WeekScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
currentYear = currentYear,
|
currentYear = today.year,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
@@ -281,8 +301,6 @@ fun WeekScreen(
|
|||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = {
|
onCreate = {
|
||||||
// Anchor on today when it's in view, else the week's first day.
|
// 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)
|
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -291,10 +309,10 @@ fun WeekScreen(
|
|||||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
WeekContent(
|
WeekContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
pagerState = pagerState,
|
||||||
onSwipeNext = goNext,
|
firstDay = firstDay,
|
||||||
onSwipePrev = goPrev,
|
week = viewModel::week,
|
||||||
onRetry = jumpToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
@@ -310,20 +328,18 @@ fun WeekScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WeekContent(
|
private fun WeekContent(
|
||||||
state: WeekUiState,
|
state: WeekUiState,
|
||||||
slideDir: Int,
|
pagerState: PagerState,
|
||||||
onSwipeNext: () -> Unit,
|
firstDay: DayOfWeek,
|
||||||
onSwipePrev: () -> Unit,
|
week: (LocalDate) -> StateFlow<WeekUiState>,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> 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-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
|
// 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
|
// noon once, on first entry into the week view (i.e. when arriving from the
|
||||||
// month/day view), not on every swipe.
|
// month/day view), not on every swipe.
|
||||||
@@ -335,62 +351,47 @@ private fun WeekContent(
|
|||||||
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
|
||||||
// week during a swipe, so the strip slides along but never jumps in height;
|
// inside the outgoing page.
|
||||||
// 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.
|
|
||||||
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 week 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 WeekUiState.Success -> "success-${s.weekStart}"
|
label = "week-state",
|
||||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
|
||||||
WeekUiState.Loading -> "loading"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
|
||||||
label = "week-transition",
|
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
WeekUiState.Loading -> WeekLoading()
|
WeekUiState.Loading -> WeekLoading()
|
||||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is WeekUiState.Success -> WeekSuccess(
|
is WeekUiState.Success -> WeekPager(
|
||||||
state = s,
|
pagerState = pagerState,
|
||||||
|
firstDay = firstDay,
|
||||||
|
week = week,
|
||||||
|
today = s.today,
|
||||||
|
initialStripHeight = s.allDayStripHeight(),
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
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)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
@Composable
|
||||||
internal fun WeekSuccess(
|
internal fun WeekSuccess(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
@@ -408,6 +526,44 @@ internal fun WeekSuccess(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> 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(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -424,13 +580,12 @@ internal fun WeekSuccess(
|
|||||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 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,
|
||||||
@@ -505,24 +660,11 @@ private fun WeekDayHeader(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val weekStart = days.first()
|
|
||||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.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 ->
|
days.forEach { date ->
|
||||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||||
val isToday = date == today
|
val isToday = date == today
|
||||||
@@ -533,16 +675,19 @@ private fun WeekDayHeader(
|
|||||||
.clickable { onOpenDay(date) },
|
.clickable { onOpenDay(date) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
|
||||||
Text(
|
Text(
|
||||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Clip,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(2.dp))
|
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.
|
// identical whether or not the week contains today.
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.size(28.dp),
|
modifier = Modifier.size(DATE_SLOT_SIZE),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (isToday) {
|
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
|
/** 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). */
|
* filled box and bold number — at the month grid's size, so the two agree (#213). */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -612,8 +784,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 columns.
|
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
|
||||||
// Span bars are positioned absolutely so a multi-day event is one
|
// Span bars are positioned absolutely so a multi-day event is one
|
||||||
// connected bar across columns rather than a chip per day. clipToBounds
|
// connected bar across columns rather than a chip per day. clipToBounds
|
||||||
// keeps bars from spilling out while the height animates.
|
// keeps bars from spilling out while the height animates.
|
||||||
@@ -682,6 +852,7 @@ private fun Timeline(
|
|||||||
state: WeekUiState.Success,
|
state: WeekUiState.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,
|
||||||
@@ -703,70 +874,61 @@ 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 columns 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 (not just at the
|
// position (not just at the day's start/end).
|
||||||
// day's start/end).
|
Box(
|
||||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
modifier = Modifier
|
||||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
.fillMaxSize()
|
||||||
// as the header badge so the labels sit under it and on the hamburger.
|
.then(pinch)
|
||||||
HourGutter(
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
scrollState = scrollState,
|
.clip(RoundedCornerShape(16.dp))
|
||||||
hourHeight = hourHeight,
|
.verticalScroll(scrollState)
|
||||||
dragController = dragController,
|
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||||
)
|
) {
|
||||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
Row(
|
||||||
Box(
|
|
||||||
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
|
||||||
|
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(
|
state.days.forEach { day ->
|
||||||
modifier = Modifier
|
DayColumnCard(
|
||||||
.fillMaxWidth()
|
blocks = state.timedByDay[day].orEmpty(),
|
||||||
.height(totalHeight)
|
dark = dark,
|
||||||
// The scrolling content itself, so its root position
|
date = day,
|
||||||
// already folds in the scroll offset.
|
today = state.today,
|
||||||
.onGloballyPositioned { coords ->
|
hourHeight = hourHeight,
|
||||||
val gap = with(density) { COLUMN_GAP.toPx() }
|
dragController = dragController,
|
||||||
dragController.geometry.let {
|
onEventClick = onEventClick,
|
||||||
it.grid = coords
|
onCreateAt = onCreateAt,
|
||||||
it.scroll = scrollState
|
onDrop = onDrop,
|
||||||
it.hourPx = with(density) { hourHeight.toPx() }
|
modifier = Modifier
|
||||||
it.blockInsetPx = blockInsetPx
|
.weight(1f)
|
||||||
it.columnGapPx = gap
|
.fillMaxHeight(),
|
||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,6 +21,7 @@ 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.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
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.map
|
||||||
@@ -30,6 +32,7 @@ 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.daysUntil
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
@@ -38,9 +41,13 @@ import java.util.Locale
|
|||||||
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
|
||||||
|
|
||||||
const val MINUTES_PER_DAY: Int = 24 * 60
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class WeekViewModel @Inject constructor(
|
class WeekViewModel @Inject constructor(
|
||||||
@@ -64,7 +71,7 @@ class WeekViewModel @Inject constructor(
|
|||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
/** 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) }
|
.map { it.resolveFirstDay(locale) }
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -73,55 +80,65 @@ class WeekViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Anchor is a representative day inside the visible week; the actual week
|
// Anchor is a representative day inside the visible week; the actual week
|
||||||
// start is derived against [weekStart], so changing the first-day preference
|
// start is derived against [firstDayOfWeek], so changing the first-day
|
||||||
// re-frames the same week instead of jumping.
|
// preference re-frames the same week instead of jumping.
|
||||||
private val _anchor = MutableStateFlow(todayDate)
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
|
|
||||||
val weekStartDate: StateFlow<LocalDate> =
|
/** The pager page the anchor's week sits on. */
|
||||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
val anchorPage: StateFlow<Int> =
|
||||||
|
combine(_anchor, firstDayOfWeek) { anchor, ws -> weekPageFor(anchor, ws) }
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
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> =
|
val state: StateFlow<WeekUiState> =
|
||||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
combine(_anchor, firstDayOfWeek) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.flatMapLatest { start ->
|
.flatMapLatest { start -> week(start).filterNot { it is WeekUiState.Loading } }
|
||||||
val range = weekRange(start, zone)
|
|
||||||
combine(
|
|
||||||
repository.calendars(),
|
|
||||||
repository.instances(range),
|
|
||||||
) { calendars, instances ->
|
|
||||||
buildState(start, calendars, instances)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = WeekUiState.Loading,
|
initialValue = WeekUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun goToPrev() {
|
private val weeks = PageStateCache<LocalDate, WeekUiState>(
|
||||||
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
|
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() {
|
/** The week starting at [start], shared between its pager page and [state]. Main thread only. */
|
||||||
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
|
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() {
|
fun goToToday() {
|
||||||
_anchor.value = todayDate
|
_anchor.value = todayDate
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump to the week containing [date] (drawer jump-to-date). */
|
|
||||||
fun goToDate(date: LocalDate) {
|
|
||||||
_anchor.value = date
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
start: LocalDate,
|
start: LocalDate,
|
||||||
calendars: List<CalendarSource>,
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user