From 164b5fb25d6c8fb266e78595529d5142c1504b7b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 9 Oct 2026 10:25:25 +0200 Subject: [PATCH] Scroll back into past days in the agenda (#367) (#382) The agenda opens on today, and scrolling up loads earlier days 30 at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today. The agenda no longer picks up the day the month or day view was on, so "Next 30 days" always counts from today. That fixes the bug from the issue comment. It also means the agenda no longer follows the shared view date from #184 when you switch to it. Backing out of a day opened from the agenda keeps your scroll position. Not in the issue: multi-day events that are still running now dim on the days already behind you, in the agenda, month, week and day views. Before, they stayed at full strength until the whole event ended. A spanning bar dims once the last day it covers in that row has passed. The widget is unchanged and still starts at today. Closes #367 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/382 --- CHANGELOG.md | 8 ++ .../calendula/ui/CalendarHost.kt | 13 +- .../calendula/ui/agenda/AgendaScreen.kt | 113 ++++++++++++++---- .../calendula/ui/agenda/AgendaUiState.kt | 23 ++-- .../calendula/ui/agenda/AgendaViewModel.kt | 71 +++++++++-- .../calendula/ui/agenda/AgendaViewPreview.kt | 1 + .../calendula/ui/common/PastEvents.kt | 16 +++ .../calendula/ui/month/MonthScreen.kt | 25 ++-- .../calendula/ui/week/WeekScreen.kt | 9 +- .../calendula/ui/agenda/AnchorTodayTest.kt | 15 +++ .../calendula/ui/common/IsPastOnTest.kt | 49 ++++++++ 11 files changed, 286 insertions(+), 57 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/IsPastOnTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a7c7cc6..9eac8dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]). + +### Fixed +- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]). +- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]). + ## [2.22.5] — 2026-10-07 ### Fixed @@ -1848,4 +1855,5 @@ automatically, with zero telemetry and no internet permission. [#361]: https://codeberg.org/jlmakiola/calendula/issues/361 [#363]: https://codeberg.org/jlmakiola/calendula/issues/363 [#364]: https://codeberg.org/jlmakiola/calendula/issues/364 +[#367]: https://codeberg.org/jlmakiola/calendula/issues/367 [#373]: https://codeberg.org/jlmakiola/calendula/issues/373 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 727a933f..a18abae0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -146,6 +146,9 @@ fun CalendarHost( mutableStateOf(listOf(defaultView)) } var navigationVersion by remember { mutableIntStateOf(0) } + // True after backing out of a day opened from the view now shown, so the + // agenda returns to where it was instead of today (#367). + var returnedFromDay by remember { mutableStateOf(false) } val view = viewStack.last() // The date every view opens on and carries along (#184); tapping a day // anywhere focuses it and opens the day view there. @@ -163,6 +166,7 @@ fun CalendarHost( val switchScope = rememberCoroutineScope() val switchTo: ((List) -> List) -> Unit = { move -> navigationVersion++ + returnedFromDay = false viewStack = move(viewStack) } val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } } @@ -322,7 +326,10 @@ fun CalendarHost( // [requestedDetailKey]. Date/event opens root the stack in the widget's own // view so backing out returns there (then home), not to the default. LaunchedEffect(widgetNavRequest) { - if (widgetNavRequest != null) navigationVersion++ + if (widgetNavRequest != null) { + navigationVersion++ + returnedFromDay = false + } when (val req = widgetNavRequest) { is WidgetNavRequest.OpenDate -> { // Drill the day view in over the widget's view: drop any overlay @@ -424,11 +431,14 @@ fun CalendarHost( if (reduceMotion) { BackHandler(enabled = viewBackEnabled) { navigationVersion++ + returnedFromDay = viewStack.last() == CalendarView.Day viewStack = viewStack.dropLast(1) } } else { PredictiveBackHandler(enabled = viewBackEnabled) { events -> val previous = viewStack[viewStack.size - 2] + // Set before the seek, which already composes the previous view. + returnedFromDay = viewStack.last() == CalendarView.Day try { events.collect { viewTransition.seekTo(it.progress, targetState = previous) } navigationVersion++ @@ -560,6 +570,7 @@ fun CalendarHost( onEventClick = onEventClick, onCreateEvent = onCreateEvent, quickSwitchViews = quickSwitchViews, + restorePosition = returnedFromDay, ) } } 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 737b0239..adbcddc2 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 @@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.ui.common.ScreenActions import de.jeanlucmakiola.calendula.ui.common.ScreenBody import de.jeanlucmakiola.calendula.ui.common.ScreenChrome import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus -import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus import kotlinx.coroutines.flow.drop import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue @@ -38,7 +37,16 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,7 +61,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.domain.hasEnded +import de.jeanlucmakiola.calendula.ui.common.isPastOn import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig @@ -82,11 +90,23 @@ fun AgendaScreen( onCreateEvent: (LocalDate, Int?) -> Unit, quickSwitchViews: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, + restorePosition: Boolean = false, viewModel: AgendaViewModel = hiltViewModel(), ) { - // Opens on the focused date and hands back the day scrolled to the top. + // Opens on today, not on the day another view was on (#367). Backing out of + // a day opened from here, or a recreated screen, returns to the day last at + // the top. The top day still goes back to the shared focus. val focus = LocalViewFocus.current - EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) } + val opened = rememberSaveable { mutableStateOf(false) } + val restoreTop = remember(viewModel) { + if (restorePosition || opened.value) { + viewModel.lastTopDate + } else { + viewModel.goToToday() + null + } + } + SideEffect { opened.value = true } val state by viewModel.state.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() @@ -106,20 +126,19 @@ fun AgendaScreen( // list scroll, not an anchor change — "today" has to bring the list back to // the top as well, and stay offered while it is scrolled away (#305). val listState = rememberLazyListState() + // Past days load above the anchor (#367), so the anchor's row counts as the + // start of the list. + val anchorIndex = remember { mutableIntStateOf(0) } val scrolledAway by remember { derivedStateOf { - listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + listState.firstVisibleItemIndex != anchorIndex.intValue || + listState.firstVisibleItemScrollOffset > 0 } } - // A new window is rendered from its first day, so the list belongs at the - // top. Keyed on the anchor the days arrived with, not the one just - // 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: () -> Unit = { - // Off today, moving the anchor is what resets the list (above); on today + // Off today, moving the anchor is what repositions the list; on today // the window doesn't change, so the scroll back is the whole action. - if (isOnToday) scope.launch { listState.animateScrollToItem(0) } + if (isOnToday) scope.launch { listState.animateScrollToItem(anchorIndex.intValue) } viewModel.goToToday() (state as? AgendaUiState.Success)?.today?.let { focus?.date = it } } @@ -188,11 +207,17 @@ fun AgendaScreen( pastDisplay = pastDisplay, showToday = showToday, listState = listState, + anchorIndex = anchorIndex, + initialTopDate = restoreTop, + onLoadEarlier = viewModel::loadEarlier, onRetry = viewModel::goToToday, onEventClick = onEventClick, onOpenDay = onOpenDay, onTopDay = if (successState?.anchor == anchor) { - { focus?.date = it } + { + focus?.date = it + viewModel.rememberTopDay(it) + } } else null, modifier = Modifier .weight(1f) @@ -294,6 +319,9 @@ internal fun AgendaContent( onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), + anchorIndex: MutableIntState = remember { mutableIntStateOf(0) }, + initialTopDate: LocalDate? = null, + onLoadEarlier: () -> Unit = {}, onTopDay: ((LocalDate) -> Unit)? = null, ) { when (state) { @@ -308,28 +336,34 @@ internal fun AgendaContent( // away (or fade) as they end while the screen stays open. val filtered = if (pastDisplay == PastEventDisplay.HIDE) { state.days.mapNotNull { day -> - val remaining = day.events.filterNot { it.hasEnded(now) } + val remaining = day.events.filterNot { it.isPastOn(day.date, now, state.zone) } if (remaining.isEmpty()) null else day.copy(events = remaining) } } else { state.days } // Anchor today with a "nothing left today" placeholder — but only when - // the window actually starts on today; a jumped-to date has no today in - // it, so anchoring there would be misleading (#35). + // the agenda actually opens on today; a jumped-to date has no today + // in it, so anchoring there would be misleading (#35). val days = anchorTodayIfMissing( days = filtered, today = state.today, enabled = showToday && state.anchor == state.today, ) - if (days.isEmpty()) { + // Past days alone don't count as anything coming up. + if (days.none { it.date >= state.anchor }) { // Nothing to scroll: drop the position a previous list left, so // the today FAB doesn't linger over an empty screen. LaunchedEffect(Unit) { listState.scrollToItem(0) } + SideEffect { anchorIndex.intValue = 0 } AgendaEmpty(modifier) } else { AgendaList( days = days, + anchor = state.anchor, + anchorIndex = anchorIndex, + initialTopDate = initialTopDate, + onLoadEarlier = onLoadEarlier, today = state.today, zone = state.zone, listState = listState, @@ -349,6 +383,10 @@ internal fun AgendaContent( @Composable private fun AgendaList( days: List, + anchor: LocalDate, + anchorIndex: MutableIntState, + initialTopDate: LocalDate?, + onLoadEarlier: () -> Unit, today: LocalDate, zone: TimeZone, listState: LazyListState, @@ -359,19 +397,49 @@ private fun AgendaList( onTopDay: ((LocalDate) -> Unit)?, modifier: Modifier = Modifier, ) { + val itemDates = agendaItemDates(days) + val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0) + SideEffect { anchorIndex.intValue = anchorRow } + // A new anchor opens the list on its row from the first frame, with past + // days above. Keyed on the anchor the days arrived with, not the one just + // requested: scrolling before the new rows compose leaves the list keyed to + // a row that reappears further down, and it follows it there. The first run + // may restore a remembered day instead. + val pendingRestore = remember { arrayOf(initialTopDate) } + DisposableEffect(anchor) { + val target = pendingRestore[0] + pendingRestore[0] = null + val index = target?.let { date -> itemDates.indexOfFirst { it >= date } } + ?.takeIf { it >= 0 } + ?: anchorRow + listState.requestScrollToItem(index) + onDispose {} + } + // Scrolling up near the first loaded day loads earlier days. + val currentLoadEarlier by rememberUpdatedState(onLoadEarlier) + val loadOnScrollUp = remember(listState) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (available.y > 0 && listState.firstVisibleItemIndex < LOAD_EARLIER_WITHIN) { + currentLoadEarlier() + } + return Offset.Zero + } + } + } if (onTopDay != null) { - val itemDates by rememberUpdatedState(agendaItemDates(days)) + val currentItemDates by rememberUpdatedState(itemDates) 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) } + .collect { index -> currentItemDates.getOrNull(index)?.let(currentOnTopDay) } } } LazyColumn( - modifier = modifier, + modifier = modifier.nestedScroll(loadOnScrollUp), state = listState, // Bottom inset clears the FAB stack so the last row stays tappable. contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), @@ -401,7 +469,7 @@ private fun AgendaList( day = day.date, zone = zone, position = positionOf(index, day.events.size), - dimmed = dimPast && event.hasEnded(now), + dimmed = dimPast && event.isPastOn(day.date, now, zone), modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)), onClick = { onEventClick(event) }, ) @@ -412,6 +480,9 @@ private fun AgendaList( } } +/** How close to the first loaded row an upward scroll starts loading earlier days. */ +private const val LOAD_EARLIER_WITHIN = 10 + /** The day of each item [AgendaList] lays out for [days], in its order. */ internal fun agendaItemDates(days: List): List = days.flatMap { day -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index f38a34df..e67e78b0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -93,22 +93,21 @@ fun groupAgendaDays( } /** - * Ensure [today] surfaces as the first agenda day even when it carries no - * (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget + * Ensure [today] surfaces in the agenda even when it carries no (remaining) + * events, by inserting an empty-event [AgendaDay] in date order that the agenda * renders as a "nothing left today" placeholder. A no-op unless [enabled], and - * when today already has its own day in [days]. Keeps today anchored at the top - * so a glance tells today's events apart from a future day's (issue #35). + * when today already has its own day in [days]. Keeps today anchored so a glance + * tells today's events apart from a future day's (issue #35). */ fun anchorTodayIfMissing( days: List, today: LocalDate, enabled: Boolean, -): List = - if (enabled && days.none { it.date == today }) { - listOf(AgendaDay(today, emptyList())) + days - } else { - days - } +): List { + if (!enabled || days.any { it.date == today }) return days + val at = days.indexOfFirst { it.date > today }.let { if (it < 0) days.size else it } + return days.take(at) + AgendaDay(today, emptyList()) + days.drop(at) +} /** * State for the Agenda view: a flat, forward-looking list of upcoming events @@ -118,8 +117,10 @@ sealed interface AgendaUiState { data object Loading : AgendaUiState data class Failure(val reason: FailureReason) : AgendaUiState data class Success( - /** First day of the loaded window (today, or a jumped-to date). */ + /** The day the agenda opens on (today, or a jumped-to date). */ val anchor: LocalDate, + /** First loaded day, before [anchor] once past days are loaded (#367). */ + val windowStart: LocalDate, val today: LocalDate, val days: List, /** The range currently in effect — the saved default or a session override. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index ae372712..785a4002 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit @@ -31,6 +32,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime +import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toInstant import kotlin.time.Instant @@ -88,8 +90,16 @@ class AgendaViewModel @Inject constructor( private val todayDate: LocalDate get() = currentDate() - private val _anchor = MutableStateFlow(todayDate) - val anchor: StateFlow = _anchor + // The anchor and how many days before it are loaded, moved together so a + // jump never queries a window that pairs the new anchor with the old span. + private val _window = MutableStateFlow(AgendaWindow(todayDate, PAST_CHUNK_DAYS)) + val anchor: StateFlow = _window + .map { it.anchor } + .stateIn(viewModelScope, SharingStarted.Eagerly, todayDate) + + /** The day last scrolled to the top, so a return to the agenda lands back on it. */ + var lastTopDate: LocalDate? = null + private set private var lastToday = todayDate @@ -101,7 +111,9 @@ class AgendaViewModel @Inject constructor( .onEach { date -> val old = lastToday lastToday = date - if (old != date && _anchor.value == old) _anchor.value = date + if (old != date && _window.value.anchor == old) { + _window.value = _window.value.copy(anchor = date) + } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate) @@ -111,9 +123,17 @@ class AgendaViewModel @Inject constructor( private val _rangeOverride = MutableStateFlow(null) val state: StateFlow = - combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart -> + combine( + _window, + agendaSettings, + _rangeOverride, + weekStart, + settingsPrefs.pastEventDisplay, + ) { window, settings, override, weekStart, pastDisplay -> AgendaParams( - anchor = anchor, + anchor = window.anchor, + // Hidden past events leave nothing to scroll back to (#367). + pastDays = if (pastDisplay == PastEventDisplay.HIDE) 0 else window.pastDays, range = override ?: settings.range, rangeIsOverride = override != null && override != settings.range, weekStart = weekStart, @@ -122,8 +142,8 @@ class AgendaViewModel @Inject constructor( } .flatMapLatest { params -> val window = agendaRange( - params.anchor, - params.range.dayCount(params.anchor, params.weekStart) - 1, + params.windowStart, + params.pastDays + params.range.dayCount(params.anchor, params.weekStart) - 1, zone, ) combine( @@ -143,12 +163,30 @@ class AgendaViewModel @Inject constructor( ) fun goToToday() { - _anchor.value = todayDate + goToDate(todayDate) } /** Jump the agenda window to start on a specific date (drawer jump-to-date). */ fun goToDate(date: LocalDate) { - _anchor.value = date + if (_window.value.anchor == date) return + _window.value = AgendaWindow(date, PAST_CHUNK_DAYS) + lastTopDate = null + } + + /** + * Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the + * previous extension is still loading, so a scroll that keeps asking only + * moves one chunk at a time. + */ + fun loadEarlier() { + val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return + val window = _window.value + if (loaded != window.start) return + _window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS) + } + + fun rememberTopDay(date: LocalDate) { + lastTopDate = date } /** Temporarily override the agenda range for this session (the bottom-left pill). */ @@ -161,13 +199,20 @@ class AgendaViewModel @Inject constructor( val showBar: Boolean, ) + private data class AgendaWindow(val anchor: LocalDate, val pastDays: Int) { + val start: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY) + } + private data class AgendaParams( val anchor: LocalDate, + val pastDays: Int, val range: AgendaRange, val rangeIsOverride: Boolean, val weekStart: DayOfWeek, val showRangeBar: Boolean, - ) + ) { + val windowStart: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY) + } private fun buildState( params: AgendaParams, @@ -181,9 +226,10 @@ class AgendaViewModel @Inject constructor( params.range.dayCount(anchor, params.weekStart) - 1, DateTimeUnit.DAY, ) - val days = groupAgendaDays(anchor, rangeEnd, instances, zone) + val days = groupAgendaDays(params.windowStart, rangeEnd, instances, zone) return AgendaUiState.Success( anchor = anchor, + windowStart = params.windowStart, today = today, days = days, range = params.range, @@ -195,6 +241,9 @@ class AgendaViewModel @Inject constructor( } } +/** How many days each scroll back into the past loads (#367). */ +internal const val PAST_CHUNK_DAYS = 30 + /** Inclusive instant range from the start of [anchor] through [days] days ahead. */ internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange { val from = anchor.atStartOfDayIn(zone) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt index e6249fea..953db711 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewPreview.kt @@ -51,6 +51,7 @@ private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.S val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY) return AgendaUiState.Success( anchor = today, + windowStart = today, today = today, days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone), range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt index cf988c10..e827a1a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt @@ -5,6 +5,11 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.hasEnded +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant /** @@ -16,6 +21,17 @@ import kotlin.time.Instant */ val LocalDimCutoff = compositionLocalOf { null } +/** + * Whether [this] counts as past where it's drawn on [day]: once it has ended, + * or on any day before the one [now] falls in, so a multi-day event still + * running doesn't stay undimmed on the days already behind it. + */ +fun EventInstance.isPastOn( + day: LocalDate, + now: Instant, + zone: TimeZone = TimeZone.currentSystemDefault(), +): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date + /** Opacity applied to a completed/past event chip when it is dimmed. */ const val EventDimAlpha = 0.4f 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 d4ed1cb3..07830045 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 @@ -171,6 +171,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff +import de.jeanlucmakiola.calendula.ui.common.isPastOn import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel @@ -1940,7 +1941,7 @@ private fun SplitDots( modifier = Modifier .morphBounds(MonthMorphKey.Event(date, event.instanceId)) .size(SPLIT_DOT_SIZE) - .alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f) + .alpha(if (dimCutoff != null && event.isPastOn(date, dimCutoff)) EventDimAlpha else 1f) .background(eventAccent(event.color, dark, soften), CircleShape), ) } @@ -1954,7 +1955,7 @@ private fun SplitDots( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .morphBounds(MonthMorphKey.Overflow(date)) - .alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f), + .alpha(if (allEnded(hidden, date, dimCutoff)) EventDimAlpha else 1f), ) } } @@ -1997,7 +1998,7 @@ private fun SplitMonthAgendaPane( day = day, zone = state.zone, position = positionOf(index, events.size), - dimmed = dimCutoff != null && event.hasEnded(dimCutoff), + dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff), modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)), onClick = { onEventClick(event) }, ) @@ -2066,7 +2067,7 @@ internal fun SplitDayPane( day = day, zone = zone, position = positionOf(index, dayEvents.size), - dimmed = dimCutoff != null && event.hasEnded(dimCutoff), + dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff), onClick = { onEventClick(event) }, // An event added to or removed from the day you are // already looking at moves its neighbours rather @@ -2542,6 +2543,7 @@ private fun MonthWeekRow( } OverflowDots( events = hiddenEvents, + day = d, total = hidden, dark = dark, rowHeight = overflowRow, @@ -2866,7 +2868,9 @@ private fun MonthBar( ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val dimCutoff = LocalDimCutoff.current - val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) + // A bar is one piece, so it dims once the last day it covers has passed. + val dimmed = dimCutoff != null && + (event.hasEnded(dimCutoff) || days?.let { event.isPastOn(it.last(), dimCutoff) } == true) val paint = eventPaint(event, dark) // The same title/secondary ink pairing the week and day blocks use, with // the time on the quieter half. @@ -2919,6 +2923,7 @@ private fun MonthBar( @Composable private fun OverflowDots( events: List, + day: LocalDate, total: Int, dark: Boolean, rowHeight: Dp, @@ -2937,7 +2942,7 @@ private fun OverflowDots( Box( modifier = Modifier .size(OVERFLOW_DOT_SIZE) - .alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f) + .alpha(if (allEnded(byColor.getValue(argb), day, dimCutoff)) EventDimAlpha else 1f) .background(eventAccent(argb, dark, soften), CircleShape), ) } @@ -2947,15 +2952,15 @@ private fun OverflowDots( text = "+$extra", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f), + modifier = Modifier.alpha(if (allEnded(events, day, dimCutoff)) EventDimAlpha else 1f), ) } } } -/** True when dimming is on and every one of [events] is already over. */ -private fun allEnded(events: List, dimCutoff: Instant?): Boolean = - dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) } +/** True when dimming is on and every one of [events] is already over on [day]. */ +private fun allEnded(events: List, day: LocalDate, dimCutoff: Instant?): Boolean = + dimCutoff != null && events.isNotEmpty() && events.all { it.isPastOn(day, dimCutoff) } @Composable private fun MonthGridLoading() { 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 384ed636..abc49568 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 @@ -98,7 +98,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.common.LocalChipGround import de.jeanlucmakiola.calendula.ui.common.eventPaint import de.jeanlucmakiola.calendula.ui.common.eventSurface @@ -135,6 +134,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController import de.jeanlucmakiola.calendula.ui.common.startInstant import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff +import de.jeanlucmakiola.calendula.ui.common.isPastOn import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.NowLine @@ -1076,6 +1076,7 @@ private fun AllDayStrip( key(bar.event.eventId, bar.event.start) { AllDayBar( event = bar.event, + lastDay = bar.toRange.endInclusive, dark = dark, onClick = { onEventClick(bar.event) }, modifier = Modifier @@ -1111,13 +1112,15 @@ private fun AllDayStrip( @Composable private fun AllDayBar( event: EventInstance, + lastDay: LocalDate, dark: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val dimCutoff = LocalDimCutoff.current - val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) + // A bar is one piece, so it dims once the last day it covers here has passed. + val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff) val paint = eventPaint(event, dark) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) @@ -1474,7 +1477,7 @@ private fun EventBlock( if (showTime) metrics.timeLine else 0.dp val timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1 val dimCutoff = LocalDimCutoff.current - val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) + val dimmed = dimCutoff != null && block.event.isPastOn(date, dimCutoff) val zone = remember { TimeZone.currentSystemDefault() } val moveAction = eventMoveAction(block.event) val draggable = eventDragAllowed(block.event) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt index 968b3cec..08afdeb5 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt @@ -9,6 +9,7 @@ class AnchorTodayTest { private val today = LocalDate(2026, 6, 17) private val tomorrow = LocalDate(2026, 6, 18) + private val yesterday = LocalDate(2026, 6, 16) @Test fun `disabled leaves the days untouched even when today is absent`() { @@ -36,4 +37,18 @@ class AnchorTodayTest { val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList())) assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days) } + + @Test + fun `enabled slots today in after loaded past days`() { + val days = listOf(AgendaDay(yesterday, emptyList()), AgendaDay(tomorrow, emptyList())) + val result = anchorTodayIfMissing(days, today, enabled = true) + assertThat(result.map { it.date }).containsExactly(yesterday, today, tomorrow).inOrder() + } + + @Test + fun `enabled appends today after only past days`() { + val days = listOf(AgendaDay(yesterday, emptyList())) + val result = anchorTodayIfMissing(days, today, enabled = true) + assertThat(result.map { it.date }).containsExactly(yesterday, today).inOrder() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/IsPastOnTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/IsPastOnTest.kt new file mode 100644 index 00000000..f4acea6e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/IsPastOnTest.kt @@ -0,0 +1,49 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class IsPastOnTest { + + private val zone = TimeZone.of("Europe/Berlin") + private val today = LocalDate(2026, 10, 8) + private val now = Instant.parse("2026-10-08T12:00:00Z") + + // All-day Oct 6–9, still running today. + private val summit = EventInstance( + instanceId = 1, + eventId = 1, + calendarId = 1, + title = "Summit", + start = Instant.parse("2026-10-06T00:00:00Z"), + end = Instant.parse("2026-10-10T00:00:00Z"), + isAllDay = true, + color = 0, + location = null, + ) + + @Test + fun `an ongoing multi-day event is past on the days already behind`() { + assertThat(summit.isPastOn(LocalDate(2026, 10, 7), now, zone)).isTrue() + } + + @Test + fun `an ongoing multi-day event is not past today or later`() { + assertThat(summit.isPastOn(today, now, zone)).isFalse() + assertThat(summit.isPastOn(LocalDate(2026, 10, 9), now, zone)).isFalse() + } + + @Test + fun `an event that ended earlier today is past`() { + val standup = summit.copy( + isAllDay = false, + start = Instant.parse("2026-10-08T07:00:00Z"), + end = Instant.parse("2026-10-08T07:15:00Z"), + ) + assertThat(standup.isPastOn(today, now, zone)).isTrue() + } +}