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