Share one focused date across views (#184)

This commit is contained in:
2026-09-27 19:18:13 +02:00
parent f9738b0c23
commit 8d3d28c240
10 changed files with 224 additions and 28 deletions
@@ -40,6 +40,9 @@ import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
@@ -120,10 +123,11 @@ fun CalendarHost(
val view = viewStack.last()
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
// The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString()
focus.date = date
viewStack = viewStack.drillToDay()
}
@@ -280,7 +284,7 @@ fun CalendarHost(
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
focus.date = LocalDate.parse(req.dateIso)
// No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
@@ -304,7 +308,7 @@ fun CalendarHost(
// default home — so backing out returns to the default, then exits.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = null
focus.date = localToday()
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed()
}
@@ -386,6 +390,7 @@ fun CalendarHost(
CompositionLocalProvider(
LocalEventMove provides moveScope,
LocalTimelineScroll provides timelineScroll,
LocalViewFocus provides focus,
// The failure state's way out when every calendar is switched off
// (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides { showCalendars = true },
@@ -438,7 +443,6 @@ fun CalendarHost(
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
@@ -674,3 +678,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
)
private fun localToday(): LocalDate =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
@@ -1,5 +1,10 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import kotlinx.coroutines.flow.drop
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -96,8 +101,15 @@ fun AgendaScreen(
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// Opens on the focused date and hands back the day scrolled to the top.
val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
val loadedState by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
// Rows from before the anchor moved belong to another window: they would
// show the wrong days, and the view switch would match the wrong events.
val state = loadedState.takeUnless { it is AgendaUiState.Success && it.anchor != anchor }
?: AgendaUiState.Loading
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
@@ -126,11 +138,12 @@ fun AgendaScreen(
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there.
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
val jumpToToday = {
val jumpToToday: () -> Unit = {
// Off today, moving the anchor is what resets the list (above); on today
// the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
viewModel.goToToday()
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
}
ModalNavigationDrawer(
@@ -146,6 +159,7 @@ fun AgendaScreen(
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
focus?.date = target
viewModel.goToDate(target)
scope.launch { drawerState.close() }
},
@@ -227,6 +241,7 @@ fun AgendaScreen(
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = { focus?.date = it },
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
@@ -328,6 +343,7 @@ internal fun AgendaContent(
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
onTopDay: ((LocalDate) -> Unit)? = null,
) {
when (state) {
AgendaUiState.Loading -> Box(modifier)
@@ -370,6 +386,7 @@ internal fun AgendaContent(
now = now,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = onTopDay,
modifier = modifier,
)
}
@@ -388,8 +405,20 @@ private fun AgendaList(
now: Instant,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onTopDay: ((LocalDate) -> Unit)?,
modifier: Modifier = Modifier,
) {
if (onTopDay != null) {
val itemDates by rememberUpdatedState(agendaItemDates(days))
val currentOnTopDay by rememberUpdatedState(onTopDay)
// The day the list opened on is where the view already is; only a
// scroll moves it on.
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.drop(1)
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
}
}
LazyColumn(
modifier = modifier,
state = listState,
@@ -432,6 +461,13 @@ private fun AgendaList(
}
}
/** The day of each item [AgendaList] lays out for [days], in its order. */
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
days.flatMap { day ->
// Header, the placeholder or the events, then the gap.
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
}
@Composable
private fun AgendaEmpty(modifier: Modifier = Modifier) {
Column(
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.datetime.LocalDate
/**
* The date every calendar view is looking at (#184), so switching views keeps
* the same days on screen and what is shown in both can travel between them.
*
* A view reads it once, as it comes on screen, and writes it back only when the
* user moves: a view settling where it opened must not nudge it, or switching
* back and forth would drift.
*/
@Stable
class ViewFocus(initial: LocalDate) {
var date by mutableStateOf(initial)
}
val ViewFocusSaver: Saver<ViewFocus, String> = Saver(
save = { it.date.toString() },
restore = { ViewFocus(LocalDate.parse(it)) },
)
val LocalViewFocus = staticCompositionLocalOf<ViewFocus?> { null }
/**
* Runs [enter] with the focused date once, as the view comes on screen, before
* it reads its own position: its first frame is then already on that date.
*/
@Composable
fun <T : Any> EnterOnFocus(key: T, enter: (LocalDate) -> Unit) {
val focus = LocalViewFocus.current
remember(key) { focus?.let { enter(it.date) } }
}
@@ -11,6 +11,8 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
@@ -215,6 +217,9 @@ fun MonthScreen(
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
) {
// Opens on the focused date's month, and hands its selection back as it moves.
val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { viewModel.goToDate(it) }
val state by viewModel.state.collectAsStateWithLifecycle()
val continuousState by viewModel.continuousState.collectAsStateWithLifecycle()
val month by viewModel.month.collectAsStateWithLifecycle()
@@ -239,7 +244,7 @@ fun MonthScreen(
// The paged and split styles' months. Made afresh whenever a paged style
// comes on screen, opening on the month the scrolling style was showing: a
// pager that was never laid out can't be scrolled to follow it meanwhile.
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
val anchorPage = monthIndexOf(month)
val pagerState = key(scrolling) {
rememberPagerState(initialPage = anchorPage) { continuousMonthCount() }
}
@@ -256,6 +261,11 @@ fun MonthScreen(
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
}
// The selection follows every move — paging, scrolling, today, a jump — so
// it is what the other views open on. Not the one this view opened with.
LaunchedEffect(viewModel) {
snapshotFlow { selectedDate }.drop(1).collect { focus?.date = it }
}
val dense = viewStyle == MonthViewStyle.Dense
// Today, from whichever state is driving; the clock only covers the first
@@ -94,15 +94,6 @@ class MonthViewModel @Inject constructor(
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
val month: StateFlow<YearMonth> = _month
/** The pager page the anchor month sits on, for the paged and split styles. */
val anchorPage: StateFlow<Int> = _month
.map { monthIndexOf(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = monthIndexOf(_month.value),
)
/**
* The anchor month's state. Once loaded it never falls back to
* [MonthUiState.Loading]: moving the anchor keeps the last result until the
@@ -93,3 +93,15 @@ data object DayGrid : PageGrid {
override fun pageFor(date: LocalDate): Int =
(DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
}
/**
* Where the focused date goes when the pager settles on [page] (#184): kept if
* the page still shows it, otherwise moved by the pages travelled, so it keeps
* its place in the page — the same weekday a week on.
*/
internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
val start = startForPage(page)
val end = start.plus(dayCount - 1, DateTimeUnit.DAY)
if (focus in start..end) return focus
return focus.plus((page - pageFor(focus)) * dayCount, DateTimeUnit.DAY).coerceIn(start, end)
}
@@ -135,6 +135,8 @@ 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.EnterOnFocus
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.TimelineGrounds
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
@@ -278,7 +280,6 @@ fun DayScreen(
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(),
) = PagedTimelineScreen(
viewModel = viewModel,
@@ -295,7 +296,6 @@ fun DayScreen(
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
modifier = modifier,
initialDate = initialDateIso?.let(LocalDate::parse),
)
/** What sets the timeline views apart; everything else they share. */
@@ -336,11 +336,14 @@ private fun PagedTimelineScreen(
drawerViewOrder: List<CalendarView>,
todayInToolbar: Boolean,
modifier: Modifier,
initialDate: LocalDate? = null,
) {
LaunchedEffect(initialDate) { initialDate?.let(viewModel::jumpTo) }
// Opens on the focused date, then carries it along as the pages move.
val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { viewModel.jumpTo(it) }
val state by viewModel.state.collectAsStateWithLifecycle()
val position by viewModel.position.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val rule by viewModel.rule.collectAsStateWithLifecycle()
val position = remember(anchor, rule) { positionOf(anchor, rule) }
val grid = position.grid
val anchorPage = position.page
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
@@ -374,7 +377,10 @@ private fun PagedTimelineScreen(
// The page it opened on is skipped: a grid change landing in between would
// read it as a different page.
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
snapshotFlow { pagerState.settledPage }.drop(1).collect { page ->
viewModel.onPageSettled(page)
focus?.let { it.date = grid.focusOnPage(it.date, page) }
}
}
// The page under the finger, so the title turns over mid-swipe, not after it.
val weekStart = grid.startForPage(pagerState.currentPage)
@@ -390,6 +396,7 @@ private fun PagedTimelineScreen(
// hasn't caught up with the fling yet to tell the difference. A date that
// would open mid-page on this grid re-frames the pages around it instead.
val jumpToDate: (LocalDate) -> Unit = { target ->
focus?.date = target
val page = grid.pageFor(target)
if (grid is DaysGrid && grid.startForPage(page) != target) {
viewModel.jumpTo(target)
@@ -56,6 +56,10 @@ data class DaySpan(val start: LocalDate, val count: Int)
/** How the pager cuts the calendar, and the page the anchor sits on. */
data class PagerPosition(val grid: PageGrid, val page: Int)
/** Where [anchor] sits under [rule]. */
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition =
rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) }
/**
* A timeline paged in runs of days: the week view, and the multi-day view (#193).
* The pager leads; the anchor follows it once it settles, and the grid it is
@@ -90,14 +94,22 @@ abstract class PagedTimelineViewModel(
// A day on the visible page; the page itself comes from the grid, so a
// settings change re-frames the same days instead of jumping.
private val _anchor = MutableStateFlow(todayDate)
val anchor: StateFlow<LocalDate> = _anchor
val position: StateFlow<PagerPosition> =
combine(_anchor, gridRule) { anchor, rule -> rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) } }
/**
* The grid for a given anchor, held eagerly so a screen can place its pager
* on its very first frame rather than a frame after the anchor moved.
*/
val rule: StateFlow<(LocalDate) -> PageGrid> =
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
private val position: StateFlow<PagerPosition> =
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
.distinctUntilChanged()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = initialRule(todayDate).let { PagerPosition(it, it.pageFor(todayDate)) },
initialValue = positionOf(todayDate, initialRule),
)
/**
@@ -141,7 +153,7 @@ abstract class PagedTimelineViewModel(
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
val grid = position.value.grid
val grid = rule.value(_anchor.value)
if (grid.pageFor(_anchor.value) != page) _anchor.value = grid.startForPage(page)
}
@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/** The day each agenda list item belongs to, for the focus it hands back (#184). */
class AgendaItemDatesTest {
private val monday = LocalDate(2026, 9, 28)
private val tuesday = LocalDate(2026, 9, 29)
private fun event(id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "Event",
start = Instant.fromEpochMilliseconds(0),
end = Instant.fromEpochMilliseconds(0),
isAllDay = false,
color = 0xFF000000.toInt(),
location = null,
)
@Test
fun `each day counts its header, events and gap`() {
val days = listOf(AgendaDay(monday, listOf(event(1), event(2))), AgendaDay(tuesday, listOf(event(3))))
assertThat(agendaItemDates(days)).containsExactly(
monday, monday, monday, monday,
tuesday, tuesday, tuesday,
).inOrder()
}
@Test
fun `an empty day counts its placeholder`() {
assertThat(agendaItemDates(listOf(AgendaDay(monday, emptyList())))).hasSize(3)
}
}
@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
/** Where the focused date goes as a timeline pages (#184). */
class FocusOnPageTest {
// 2026-09-30 is a Wednesday.
private val wednesday = LocalDate(2026, 9, 30)
@Test
fun `a page that still shows the focus keeps it`() {
val grid = WeekGrid(DayOfWeek.MONDAY)
assertThat(grid.focusOnPage(wednesday, grid.pageFor(wednesday))).isEqualTo(wednesday)
}
@Test
fun `a week on keeps the weekday`() {
val grid = WeekGrid(DayOfWeek.MONDAY)
val page = grid.pageFor(wednesday)
assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 7))
assertThat(grid.focusOnPage(wednesday, page - 2)).isEqualTo(LocalDate(2026, 9, 16))
}
@Test
fun `a run of days keeps the focus's column`() {
val grid = DaysGrid.startingAt(LocalDate(2026, 9, 29), 3)
val page = grid.pageFor(wednesday)
assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 3))
}
@Test
fun `a day page is the focus`() {
val page = DayGrid.pageFor(wednesday)
assertThat(DayGrid.focusOnPage(wednesday, page + 5)).isEqualTo(LocalDate(2026, 10, 5))
}
}