From e1cf00999d86c8ae902830368c21c44344a42249 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 21:31:23 +0200 Subject: [PATCH] feat(views): optionally dim or hide past events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two independent display settings under Settings › Appearance, both defaulting to the current behaviour (off) so nothing changes until a user opts in: • Agenda › "Past events" (Show / Dim / Hide) — events that already ended today can be left as-is, faded, or dropped from the list. Hiding also removes any day left empty, falling back to the empty state. Re-evaluated each minute so rows fade/fall away as they end while the screen is open. • Calendar › "Dim completed events" — a separate toggle that fades finished events in the month and week grids, kept independent of the agenda setting. An event counts as completed once its end is at or before now (in-progress events are never dimmed; all-day events only after their day is fully over), via a shared EventInstance.hasEnded(now). The grids read the cut-off through a new LocalDimCutoff CompositionLocal (mirroring LocalShowHourLines) so only the event chips recompose on the per-minute tick, and only while dimming is on. Also adds an "Agenda" section header so the agenda rows stand apart in the now-busier Appearance screen, and documents the feature in the changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 ++++ .../calendula/data/prefs/SettingsPrefs.kt | 34 +++++++++++ .../jeanlucmakiola/calendula/domain/Models.kt | 8 +++ .../calendula/ui/agenda/AgendaScreen.kt | 47 +++++++++++++--- .../calendula/ui/agenda/AgendaViewModel.kt | 13 +++++ .../calendula/ui/common/NowLine.kt | 2 +- .../calendula/ui/common/PastEvents.kt | 16 ++++++ .../calendula/ui/month/MonthScreen.kt | 37 +++++++++--- .../calendula/ui/month/MonthViewModel.kt | 8 +++ .../calendula/ui/settings/SettingsScreen.kt | 56 ++++++++++++++++++- .../calendula/ui/settings/SettingsUiState.kt | 5 ++ .../ui/settings/SettingsViewModel.kt | 15 +++++ .../calendula/ui/week/WeekScreen.kt | 51 ++++++++++++----- .../calendula/ui/week/WeekViewModel.kt | 8 +++ app/src/main/res/values/strings.xml | 7 +++ 15 files changed, 284 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b75b89a..cece270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- See past events your way. Two new settings change how events that have already + ended are shown. **Past events** (Settings → Appearance → Agenda) lets the + agenda keep them as usual, dim them, or hide them from the list entirely. + **Dim completed events** (Settings → Appearance) separately fades finished + events in the month and week views. Both are off by default, an event only + counts as finished once it has actually ended (events still in progress are + never dimmed), and the list updates on its own as the day goes on. + ## [2.11.2] — 2026-06-28 ### Added diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 2d3b9e0..7e574ca 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -38,6 +38,13 @@ sealed interface WeekStartPref { */ enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR } +/** + * How the Agenda screen treats events that have already finished today: [SHOW] + * leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops + * them from the list entirely. + */ +enum class PastEventDisplay { SHOW, DIM, HIDE } + /** * Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the * device's `DateFormat.is24HourFormat` value). @@ -116,6 +123,31 @@ class SettingsPrefs @Inject constructor( store.edit { it[SHOW_HOUR_LINES_KEY] = enabled } } + /** + * How the Agenda screen treats events that already ended today. Defaults to + * [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming + * or hiding. + */ + val pastEventDisplay: Flow = store.data.map { prefs -> + prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW) + } + + suspend fun setPastEventDisplay(mode: PastEventDisplay) { + store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name } + } + + /** + * Whether the month/week grids fade events that have already finished. + * Defaults to OFF — independent of the Agenda's [pastEventDisplay]. + */ + val dimCompletedEvents: Flow = store.data.map { prefs -> + prefs[DIM_COMPLETED_EVENTS_KEY] ?: false + } + + suspend fun setDimCompletedEvents(enabled: Boolean) { + store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled } + } + /** * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to * [AgendaRange.Month] — a month of upcoming events. Independent of the @@ -404,6 +436,8 @@ class SettingsPrefs @Inject constructor( internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar") internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") + internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") + internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index b487ec9..5d5b89f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -40,6 +40,14 @@ data class EventInstance( val location: String?, ) +/** + * Whether this event has finished relative to [now] — its end is at or before + * the current instant. An in-progress event (already started but not yet ended) + * is *not* considered ended. All-day events end at the exclusive next-midnight, + * so they only count as ended once their day is fully over. + */ +fun EventInstance.hasEnded(now: Instant): Boolean = end <= now + data class EventDetail( val instance: EventInstance, val description: String?, 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 8c6073c..4db125c 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 @@ -45,6 +45,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -53,7 +54,9 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel 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.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem @@ -61,12 +64,14 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.positionOf +import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay @@ -96,6 +101,7 @@ fun AgendaScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle() + val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -178,6 +184,7 @@ fun AgendaScreen( } AgendaContent( state = state, + pastDisplay = pastDisplay, onRetry = viewModel::goToToday, onEventClick = onEventClick, modifier = Modifier @@ -275,6 +282,7 @@ private fun AgendaRangeBanner( @Composable private fun AgendaContent( state: AgendaUiState, + pastDisplay: PastEventDisplay, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, @@ -284,19 +292,42 @@ private fun AgendaContent( is AgendaUiState.Failure -> Box(modifier) { CalendarFailure(reason = state.reason, onRetry = onRetry) } - is AgendaUiState.Success -> - if (state.days.isEmpty()) { + is AgendaUiState.Success -> { + val now by rememberCurrentMinute() + // Hiding drops finished events — and any day they leave empty; dimming + // keeps them but fades the row. Recomputed each minute so events fall + // away (or fade) as they end while the screen stays open. + val days = if (pastDisplay == PastEventDisplay.HIDE) { + state.days.mapNotNull { day -> + val remaining = day.events.filterNot { it.hasEnded(now) } + if (remaining.isEmpty()) null else day.copy(events = remaining) + } + } else { + state.days + } + if (days.isEmpty()) { AgendaEmpty(modifier) } else { - AgendaList(state = state, onEventClick = onEventClick, modifier = modifier) + AgendaList( + days = days, + today = state.today, + dimPast = pastDisplay == PastEventDisplay.DIM, + now = now, + onEventClick = onEventClick, + modifier = modifier, + ) } + } } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun AgendaList( - state: AgendaUiState.Success, + days: List, + today: LocalDate, + dimPast: Boolean, + now: Instant, onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, ) { @@ -305,9 +336,9 @@ private fun AgendaList( // Bottom inset clears the FAB stack so the last row stays tappable. contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), ) { - state.days.forEach { day -> + days.forEach { day -> stickyHeader(key = "header-${day.date}") { - AgendaDayHeader(date = day.date, today = state.today) + AgendaDayHeader(date = day.date, today = today) } itemsIndexed( items = day.events, @@ -316,6 +347,7 @@ private fun AgendaList( AgendaEventRow( event = event, position = positionOf(index, day.events.size), + dimmed = dimPast && event.hasEnded(now), modifier = calendarAnimateItem(), onClick = { onEventClick(event) }, ) @@ -348,13 +380,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) { private fun AgendaEventRow( event: EventInstance, position: Position, + dimmed: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit, ) { val dark = isSystemInDarkTheme() val title = event.title.ifBlank { stringResource(R.string.event_untitled) } GroupedRow( - modifier = modifier, + modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, title = title, summary = agendaTimeSummary(event), position = position, 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 f088e4e..5e00ac1 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 @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.CalendarSource @@ -53,6 +54,18 @@ class AgendaViewModel @Inject constructor( private val weekStartDay = settingsPrefs.weekStart .map { it.resolveFirstDay(Locale.getDefault()) } + /** + * How to treat events that already ended today (show / dim / hide). A display + * concern only, so it rides alongside the data state rather than re-querying; + * the screen combines it with a per-minute "now" to fade or drop past rows. + */ + val pastEventDisplay: StateFlow = settingsPrefs.pastEventDisplay + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = PastEventDisplay.SHOW, + ) + private val zone = TimeZone.currentSystemDefault() private val todayDate: LocalDate diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt index faa243f..b3f800d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt @@ -34,7 +34,7 @@ private val LineThickness = 2.dp * the middle of a minute. Drives the "now" line in the day/week grids. */ @Composable -private fun rememberCurrentMinute(): State { +fun rememberCurrentMinute(): State { val instant = remember { mutableStateOf(Clock.System.now()) } LaunchedEffect(Unit) { while (true) { 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 new file mode 100644 index 0000000..d2b9056 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt @@ -0,0 +1,16 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.compositionLocalOf +import kotlin.time.Instant + +/** + * The cut-off instant before which a calendar event counts as "completed" and is + * drawn dimmed in the month/week grids — i.e. the current wall-clock minute when + * the "dim completed events" setting is on, or `null` when it is off (nothing + * dims). Provided per grid screen so only the event chips that read it recompose + * as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat]. + */ +val LocalDimCutoff = compositionLocalOf { null } + +/** 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 d5a1ca4..f57f6e3 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 @@ -36,6 +36,8 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -44,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color @@ -60,10 +63,14 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff +import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec @@ -97,6 +104,14 @@ fun MonthScreen( val state by viewModel.state.collectAsStateWithLifecycle() val month by viewModel.month.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() + val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() + // The instant before which an event counts as completed, or null when dimming + // is off. derivedStateOf keeps the per-minute "now" from recomposing the + // screen while the setting is off (it stays null regardless of the tick). + val nowState = rememberCurrentMinute() + val dimCutoff by remember(dimCompleted) { + derivedStateOf { if (dimCompleted) nowState.value else null } + } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -193,14 +208,16 @@ fun MonthScreen( .fillMaxSize(), ) { WeekdayHeader(weekStart = weekStart) - MonthContent( - state = state, - slideDir = slideDir, - onSwipeNext = goNext, - onSwipePrev = goPrev, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - ) + CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { + MonthContent( + state = state, + slideDir = slideDir, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + ) + } } } } @@ -558,6 +575,8 @@ private fun MonthBar( modifier: Modifier = Modifier, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + val dimCutoff = LocalDimCutoff.current + val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val shape = RoundedCornerShape( topStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp, @@ -565,7 +584,7 @@ private fun MonthBar( bottomEnd = if (continuesRight) 0.dp else 4.dp, ) Box( - modifier = modifier + modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) .background(pastelize(event.color, dark), shape) .padding(horizontal = 4.dp) .semantics { contentDescription = title }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 6f00069..2804a6b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -59,6 +59,14 @@ class MonthViewModel @Inject constructor( initialValue = DayOfWeek.MONDAY, ) + /** Whether to fade events that have already finished (display concern only). */ + val dimCompletedEvents: StateFlow = settingsPrefs.dimCompletedEvents + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = false, + ) + private val todayDate: LocalDate get() = Clock.System.now().toLocalDateTime(zone).date diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index c0be33e..d3fc450 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -86,6 +86,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref @@ -475,6 +476,7 @@ private fun AppearanceScreen( var showDefaultView by remember { mutableStateOf(false) } var showAgendaScreenRange by remember { mutableStateOf(false) } var showAgendaWidgetRange by remember { mutableStateOf(false) } + var showPastEvents by remember { mutableStateOf(false) } CollapsingScaffold( title = stringResource(R.string.settings_section_appearance), @@ -533,7 +535,7 @@ private fun AppearanceScreen( GroupedRow( title = stringResource(R.string.settings_hour_lines), summary = stringResource(R.string.settings_hour_lines_summary), - position = Position.Bottom, + position = Position.Middle, trailing = { Switch( checked = state.showHourLines, @@ -542,16 +544,36 @@ private fun AppearanceScreen( }, onClick = { viewModel.setShowHourLines(!state.showHourLines) }, ) + GroupedRow( + title = stringResource(R.string.settings_dim_completed), + summary = stringResource(R.string.settings_dim_completed_summary), + position = Position.Bottom, + trailing = { + Switch( + checked = state.dimCompletedEvents, + onCheckedChange = viewModel::setDimCompletedEvents, + ) + }, + onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) }, + ) Spacer(Modifier.height(16.dp)) - // Agenda + // Agenda — the only group labelled, so its agenda-specific rows are easy + // to pick out among the otherwise unheadered Appearance settings. + SectionHeader(stringResource(R.string.settings_agenda_header)) GroupedRow( title = stringResource(R.string.settings_agenda_range), summary = agendaRangeLabel(state.agendaScreenRange), position = Position.Top, onClick = { showAgendaScreenRange = true }, ) + GroupedRow( + title = stringResource(R.string.settings_past_events), + summary = pastEventDisplayLabel(state.pastEventDisplay), + position = Position.Middle, + onClick = { showPastEvents = true }, + ) GroupedRow( title = stringResource(R.string.settings_agenda_widget_range), summary = agendaRangeLabel(state.agendaWidgetRange), @@ -620,6 +642,16 @@ private fun AppearanceScreen( onDismiss = { showTimeFormat = false }, ) } + if (showPastEvents) { + OptionPicker( + title = stringResource(R.string.settings_past_events), + options = PastEventDisplay.entries, + selected = state.pastEventDisplay, + label = { pastEventDisplayLabel(it) }, + onSelect = viewModel::setPastEventDisplay, + onDismiss = { showPastEvents = false }, + ) + } if (showDefaultView) { OptionPicker( title = stringResource(R.string.settings_default_view), @@ -1170,6 +1202,26 @@ private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource( }, ) +/** A small primary-coloured group label, matching the Calendars settings screen. */ +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResource( + when (mode) { + PastEventDisplay.SHOW -> R.string.settings_past_events_show + PastEventDisplay.DIM -> R.string.settings_past_events_dim + PastEventDisplay.HIDE -> R.string.settings_past_events_hide + }, +) + @Composable private fun languageLabel(tag: String?): String = if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 8996709..52c23ae 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.settings +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref @@ -24,6 +25,10 @@ data class SettingsUiState( val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, /** Whether the week/day timeline draws an hour separator line (v2.11). */ val showHourLines: Boolean = false, + /** How the Agenda screen treats events that already ended today. */ + val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW, + /** Whether the month/week grids fade events that have already finished. */ + val dimCompletedEvents: Boolean = false, /** How far ahead the in-app Agenda screen shows events (v2.11). */ val agendaScreenRange: AgendaRange = AgendaRange.Month, /** How far ahead the agenda widget shows events (v2.11). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index e0bcd8a..b0bea35 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -11,6 +11,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref @@ -99,6 +100,8 @@ class SettingsViewModel @Inject constructor( combine( prefs.agendaShowRangeBar, prefs.autofocusEventTitle, + prefs.pastEventDisplay, + prefs.dimCompletedEvents, ::MiscSettings, ), ) { base, defaults, overrides, views, misc -> @@ -110,6 +113,8 @@ class SettingsViewModel @Inject constructor( showHourLines = views.showHourLines, agendaShowRangeBar = misc.showRangeBar, autofocusEventTitle = misc.autofocusEventTitle, + pastEventDisplay = misc.pastEventDisplay, + dimCompletedEvents = misc.dimCompletedEvents, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -156,6 +161,8 @@ class SettingsViewModel @Inject constructor( private data class MiscSettings( val showRangeBar: Boolean, val autofocusEventTitle: Boolean, + val pastEventDisplay: PastEventDisplay, + val dimCompletedEvents: Boolean, ) fun setThemeMode(mode: ThemeMode) { @@ -211,6 +218,14 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setShowHourLines(enabled) } } + fun setPastEventDisplay(mode: PastEventDisplay) { + viewModelScope.launch { prefs.setPastEventDisplay(mode) } + } + + fun setDimCompletedEvents(enabled: Boolean) { + viewModelScope.launch { prefs.setDimCompletedEvents(enabled) } + } + fun setDefaultView(view: CalendarView) { viewModelScope.launch { prefs.setDefaultView(view) } } 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 63bbe74..124636e 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 @@ -45,7 +45,9 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -55,6 +57,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color @@ -73,11 +76,15 @@ 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.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.NowLine +import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec @@ -129,6 +136,14 @@ fun WeekScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle() + val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() + // The instant before which an event counts as completed, or null when dimming + // is off. derivedStateOf keeps the per-minute "now" from recomposing the + // screen while the setting is off (it stays null regardless of the tick). + val nowState = rememberCurrentMinute() + val dimCutoff by remember(dimCompleted) { + derivedStateOf { if (dimCompleted) nowState.value else null } + } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -221,19 +236,21 @@ fun WeekScreen( ) }, ) { innerPadding -> - WeekContent( - state = state, - slideDir = slideDir, - topSectionColor = topSectionColor, - onSwipeNext = goNext, - onSwipePrev = goPrev, - onRetry = jumpToToday, - onEventClick = onEventClick, - onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) + CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { + WeekContent( + state = state, + slideDir = slideDir, + topSectionColor = topSectionColor, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + onEventClick = onEventClick, + onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } } } } @@ -550,8 +567,10 @@ private fun AllDayBar( modifier: Modifier = Modifier, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + val dimCutoff = LocalDimCutoff.current + val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) Box( - modifier = modifier + modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) .background(pastelize(event.color, dark), RoundedCornerShape(4.dp)) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) @@ -721,8 +740,10 @@ private fun EventBlock( val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + minToHm(block.endMin, use24Hour, locale) val showTime = block.endMin - block.startMin >= 45 + val dimCutoff = LocalDimCutoff.current + val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) Box( - modifier = modifier + modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) .background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) .clickable(onClick = onClick) .padding(horizontal = 4.dp, vertical = 2.dp) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 3a73e5d..71f603c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -50,6 +50,14 @@ class WeekViewModel @Inject constructor( private val zone = TimeZone.currentSystemDefault() private val locale: Locale = Locale.getDefault() + /** Whether to fade events that have already finished (display concern only). */ + val dimCompletedEvents: StateFlow = settingsPrefs.dimCompletedEvents + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = false, + ) + private val todayDate: LocalDate get() = Clock.System.now().toLocalDateTime(zone).date diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b85a5e..d121f90 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -282,6 +282,13 @@ 24-hour (14:00) Hour lines Show a separator line at each hour in week and day view + Dim completed events + Fade events that have already ended in month and week view + Past events + Show + Dim + Hide + Agenda Agenda range How far ahead the Agenda screen lists events. Agenda widget range