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.
This commit is contained in:
2026-10-08 16:46:41 +02:00
parent 6afcfd49f1
commit c42b7edc3a
10 changed files with 278 additions and 57 deletions
@@ -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<CalendarView>) -> List<CalendarView>) -> 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,
)
}
}
@@ -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<CalendarView> = 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<AgendaDay>,
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<AgendaDay>): List<LocalDate> =
days.flatMap { day ->
@@ -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<AgendaDay>,
today: LocalDate,
enabled: Boolean,
): List<AgendaDay> =
if (enabled && days.none { it.date == today }) {
listOf(AgendaDay(today, emptyList())) + days
} else {
days
}
): List<AgendaDay> {
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<AgendaDay>,
/** The range currently in effect — the saved default or a session override. */
@@ -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<LocalDate> = _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<LocalDate> = _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<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> =
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<Instant> {
val from = anchor.atStartOfDayIn(zone)
@@ -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),
@@ -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<Instant?> { 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
@@ -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<EventInstance>,
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<EventInstance>, 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<EventInstance>, day: LocalDate, dimCutoff: Instant?): Boolean =
dimCutoff != null && events.isNotEmpty() && events.all { it.isPastOn(day, dimCutoff) }
@Composable
private fun MonthGridLoading() {
@@ -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)
@@ -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()
}
}
@@ -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()
}
}