From c42b7edc3ad23678b0cadd6269cac18aa0f027fc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 8 Oct 2026 16:46:41 +0200 Subject: [PATCH] Scroll back into past days in the agenda (#367) Agenda opens on today instead of the day another view was on, loads earlier days as you scroll up, and dims past days of multi-day events in every view. --- .../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 ++++++++ 10 files changed, 278 insertions(+), 57 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/IsPastOnTest.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 727a933f..709c087d 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) } + // Whether the view on show was reached by backing out of a day drilled into + // from it, 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..11b1580d 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 rather than the day another view was on (#367), unless + // backing out of a day opened from here or recreated, which land on the day + // last at the top. Still hands the top day 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 "at the start" is the anchor's + // row rather than the top 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()) { + // Loaded past days alone aren't 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, past days above it, from the first + // frame. 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 reaches further back. + 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..9f34a7bb 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 is behind. + 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..51c225f7 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 is behind. + 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() + } +}