From 8d3d28c240b708ac53bc9d3cafedce653afd872b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 19:18:13 +0200 Subject: [PATCH] Share one focused date across views (#184) --- .../calendula/ui/CalendarHost.kt | 19 ++++++--- .../calendula/ui/agenda/AgendaScreen.kt | 40 +++++++++++++++++- .../calendula/ui/common/ViewFocus.kt | 41 +++++++++++++++++++ .../calendula/ui/month/MonthScreen.kt | 12 +++++- .../calendula/ui/month/MonthViewModel.kt | 9 ---- .../calendula/ui/week/WeekPaging.kt | 12 ++++++ .../calendula/ui/week/WeekScreen.kt | 19 ++++++--- .../calendula/ui/week/WeekViewModel.kt | 20 +++++++-- .../ui/agenda/AgendaItemDatesTest.kt | 40 ++++++++++++++++++ .../calendula/ui/week/FocusOnPageTest.kt | 40 ++++++++++++++++++ 10 files changed, 224 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 345c75a..5324f94 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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(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, 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index ca64ec5..2386ce0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -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): List = + 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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt new file mode 100644 index 0000000..74ac346 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt @@ -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 = Saver( + save = { it.date.toString() }, + restore = { ViewFocus(LocalDate.parse(it)) }, +) + +val LocalViewFocus = staticCompositionLocalOf { 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 EnterOnFocus(key: T, enter: (LocalDate) -> Unit) { + val focus = LocalViewFocus.current + remember(key) { focus?.let { enter(it.date) } } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index c31a96f..d12529c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 04a5af2..eddb01a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -94,15 +94,6 @@ class MonthViewModel @Inject constructor( private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) val month: StateFlow = _month - /** The pager page the anchor month sits on, for the paged and split styles. */ - val anchorPage: StateFlow = _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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt index e9f43f6..e19ed1e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 4e67dd3..a26c8eb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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 = 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, 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 729db3c..2acd4ef 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -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 = _anchor - val position: StateFlow = - 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 = + 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) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt new file mode 100644 index 0000000..4e5c7b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt new file mode 100644 index 0000000..96e5025 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt @@ -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)) + } +}