Page the week view with a pager that follows the finger (#131)
Closes #131
This commit is contained in:
@@ -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,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,24 @@
|
|||||||
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.core.Animatable
|
||||||
|
import androidx.compose.animation.core.VectorConverter
|
||||||
|
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 androidx.compose.ui.unit.lerp
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlin.math.floor
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -48,12 +65,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 +84,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 +138,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 +156,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 +165,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 +174,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 +206,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 +220,47 @@ 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 {
|
||||||
|
if (reduceMotion) {
|
||||||
|
pagerState.scrollToPage(page)
|
||||||
|
} else {
|
||||||
|
pagerState.animateScrollToPage(page, animationSpec = pageSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
val jumpToToday = { jumpToDate(today) }
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -263,7 +292,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 +310,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 +318,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 +337,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 +360,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 +408,150 @@ 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One all-day strip height for every page, blended between the two weeks a swipe
|
||||||
|
* sits between so their timelines stay level mid-swipe. It follows the swipe
|
||||||
|
* directly and only springs when a week's own strip changes at rest. [fallback]
|
||||||
|
* stands in until the pages have reported theirs, so it doesn't open from zero.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun pagedStripHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
|
||||||
|
val currentFallback by rememberUpdatedState(fallback)
|
||||||
|
val target by remember(pagerState, heights) {
|
||||||
|
derivedStateOf {
|
||||||
|
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
||||||
|
val from = floor(position).toInt()
|
||||||
|
val a = heights[from]
|
||||||
|
val b = heights[from + 1]
|
||||||
|
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val height = remember { Animatable(target, Dp.VectorConverter) }
|
||||||
|
LaunchedEffect(height) {
|
||||||
|
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
|
||||||
|
if (scrolling) height.snapTo(h) else height.animateTo(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return height.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A week with no events yet, for a page whose week is still loading. */
|
||||||
|
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 +562,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 +616,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 +696,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 +711,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 +753,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 +820,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 +888,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 +910,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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,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 +31,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 +40,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 +70,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 +79,70 @@ 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)) }
|
|
||||||
.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 = HashMap<LocalDate, StateFlow<WeekUiState>>()
|
||||||
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
|
|
||||||
|
/**
|
||||||
|
* The week starting at [start], shared between its pager page and [state].
|
||||||
|
* Weeks far from the one asked for are dropped, so paging through a year
|
||||||
|
* doesn't keep a year of queries around. Main thread only.
|
||||||
|
*/
|
||||||
|
fun week(start: LocalDate): StateFlow<WeekUiState> {
|
||||||
|
weeks.keys.removeAll { abs(it.daysUntil(start)) > WEEK_CACHE_DAYS }
|
||||||
|
return weeks.getOrPut(start) {
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(weekRange(start, zone)),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(start, calendars, instances)
|
||||||
|
}
|
||||||
|
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = WeekUiState.Loading,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToNext() {
|
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||||
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
|
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,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