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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user