From e1cf00999d86c8ae902830368c21c44344a42249 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 21:31:23 +0200 Subject: [PATCH 1/7] 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 -- 2.49.1 From 49ddb9437b915544f0bfd43a6c1a88e08ffd3a27 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 21:58:22 +0200 Subject: [PATCH 2/7] feat(week): wrap event title to fill slim overlapping blocks Overlapping events split a day column into narrow lanes where the title was clipped to one or two characters. Mirror Google Calendar: drop the time label on overlapping (multi-lane) blocks and wrap the title across as many lines as the block height allows, so the full title stays readable without opening the event. Closes #13 --- .../calendula/ui/week/WeekScreen.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) 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..5d210f5 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 @@ -692,6 +692,7 @@ private fun DayColumnCard( EventBlock( block = block, dark = dark, + height = height, onClick = { onEventClick(block.event) }, modifier = Modifier .offset(x = laneWidth * block.lane, y = top) @@ -712,6 +713,7 @@ private fun DayColumnCard( private fun EventBlock( block: TimedBlock, dark: Boolean, + height: Dp, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -720,7 +722,22 @@ private fun EventBlock( val locale = currentLocale() val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + minToHm(block.endMin, use24Hour, locale) - val showTime = block.endMin - block.startMin >= 45 + // Only full-width (non-overlapping) blocks that are tall enough show the time. + // On narrow overlapping columns we drop it so the title can wrap to fill the + // whole block, mirroring Google Calendar. + val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1 + val density = LocalDensity.current + val titleLineHeight = with(density) { + MaterialTheme.typography.labelMedium.lineHeight.toDp() + } + val timeLineHeight = with(density) { + MaterialTheme.typography.labelSmall.lineHeight.toDp() + } + // Wrap the title across as many lines as the block can fit (minus the 2.dp + // top/bottom padding and the reserved time line) instead of clipping it to a + // single character on slim, overlapping blocks. + val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp + val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) Box( modifier = modifier .background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) @@ -732,18 +749,15 @@ private fun EventBlock( Text( text = title, style = MaterialTheme.typography.labelMedium, - maxLines = if (showTime) 1 else 2, + maxLines = titleMaxLines, overflow = TextOverflow.Ellipsis, color = Color.Black.copy(alpha = 0.85f), ) if (showTime) { - // Narrow columns can't fit "13:00–14:00" on one line, so let it - // wrap to a second line (after the dash) instead of clipping the - // end time. Text( text = timeLabel, style = MaterialTheme.typography.labelSmall, - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.Black.copy(alpha = 0.6f), ) -- 2.49.1 From 93df6d6d627f217460501ecf9260583b7c859bb5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 22:01:26 +0200 Subject: [PATCH 3/7] =?UTF-8?q?release:=20v2.12.0=20=E2=80=94=20wrap=20eve?= =?UTF-8?q?nt=20titles=20in=20week=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump versionName to 2.12.0 (versionCode 21200) and add the changelog. Merging this into main triggers release.yaml, which builds, signs, publishes and mints the v2.12.0 tag + Gitea release. Closes #13 --- app/build.gradle.kts | 4 ++-- fastlane/metadata/android/en-US/changelogs/21200.txt | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/21200.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c6b0066..b20a171 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 21102 - versionName = "2.11.2" + versionCode = 21200 + versionName = "2.12.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/fastlane/metadata/android/en-US/changelogs/21200.txt b/fastlane/metadata/android/en-US/changelogs/21200.txt new file mode 100644 index 0000000..c7dc64e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21200.txt @@ -0,0 +1,7 @@ +### Changed +- Readable titles on overlapping events. In the week and day views, events that + overlap split a day into slim columns where the title used to be clipped to a + character or two. The title now wraps across as many lines as the block can + fit, and the time is hidden on those narrow blocks so the name can fill the + space — so you can tell events apart without opening each one. Thanks to @ptab + for the suggestion ([#13]). -- 2.49.1 From feb87a1e733488882561fe117c3c7d4166fecbcb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 22:16:37 +0200 Subject: [PATCH 4/7] docs(changelog): record the week-view title-wrap change for v2.12.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlapping-title fix already shipped its F-Droid note in fastlane/.../21200.txt but was never added to CHANGELOG.md, so the release-cut sync (which regenerates the fastlane note from the changelog's version section) would have dropped it. Add it under [Unreleased] › Changed alongside the past-event display settings, and define the missing [#13] link. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cece270..71ed0b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. +### Changed +- Readable titles on overlapping events. In the week and day views, events that + overlap split a day into slim columns where the title used to be clipped to a + character or two. The title now wraps across as many lines as the block can + fit, and the time is hidden on those narrow blocks so the name can fill the + space — so you can tell events apart without opening each one. Thanks to @ptab + for the suggestion ([#13]). + ## [2.11.2] — 2026-06-28 ### Added @@ -717,3 +725,4 @@ automatically, with zero telemetry and no internet permission. [#8]: https://codeberg.org/jlmakiola/calendula/issues/8 [#9]: https://codeberg.org/jlmakiola/calendula/issues/9 [#10]: https://codeberg.org/jlmakiola/calendula/issues/10 +[#13]: https://codeberg.org/jlmakiola/calendula/issues/13 -- 2.49.1 From 3f27a47d1ed72e46fcabe7504de5978947e336b5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 22:21:24 +0200 Subject: [PATCH 5/7] docs(changelog): credit Codeberg #12 (@ptab) for the past-events feature Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ed0b2..e5b75b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **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. + never dimmed), and the list updates on its own as the day goes on. Thanks to + @ptab for the suggestion ([#12]). ### Changed - Readable titles on overlapping events. In the week and day views, events that @@ -725,4 +726,5 @@ automatically, with zero telemetry and no internet permission. [#8]: https://codeberg.org/jlmakiola/calendula/issues/8 [#9]: https://codeberg.org/jlmakiola/calendula/issues/9 [#10]: https://codeberg.org/jlmakiola/calendula/issues/10 +[#12]: https://codeberg.org/jlmakiola/calendula/issues/12 [#13]: https://codeberg.org/jlmakiola/calendula/issues/13 -- 2.49.1 From e258a4a7f2a3a07e38b71937ae28fce3186496d1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 22:29:19 +0200 Subject: [PATCH 6/7] feat(widget): apply the past-event display setting to the agenda widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #12 asks for hiding finished events in the agenda view *and* its home-screen widget. The in-app screen already honours the Past events setting (Show / Dim / Hide); extend the same setting to the "Upcoming" agenda widget. The widget loads its events unfiltered and applies the mode reactively in the composition against a captured "now" — mirroring how the range is read from per-instance Glance state — so toggling the setting reflects on a live widget without relying on provideGlance's data preamble re-running. Hiding drops finished events and any day they empty; dimming fades the colour stripe and lowers the title's emphasis (Glance has no generic alpha modifier). setPastEventDisplay now pushes the mode into each widget's Glance state and recomposes, like setAgendaWidgetRange. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 ++--- .../calendula/data/prefs/SettingsPrefs.kt | 6 +++ .../ui/settings/SettingsViewModel.kt | 15 +++++- .../calendula/widget/WidgetData.kt | 11 +++++ .../calendula/widget/agenda/AgendaWidget.kt | 49 +++++++++++++++++-- 5 files changed, 82 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b75b8..d6ee6d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. Thanks to - @ptab for the suggestion ([#12]). + agenda — both in the app and the home-screen "Upcoming" widget — 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 lists + update on their own as the day goes on. Thanks to @ptab for the suggestion + ([#12]). ### Changed - Readable titles on overlapping events. In the week and day views, events that 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 7e574ca..1a1f1d5 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 @@ -45,6 +45,12 @@ enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR } */ enum class PastEventDisplay { SHOW, DIM, HIDE } +/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */ +fun parsePastEventDisplay( + stored: String?, + default: PastEventDisplay = PastEventDisplay.SHOW, +): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default + /** * Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the * device's `DateFormat.is24HourFormat` value). 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 b0bea35..557ab21 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 @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget @@ -219,7 +220,19 @@ class SettingsViewModel @Inject constructor( } fun setPastEventDisplay(mode: PastEventDisplay) { - viewModelScope.launch { prefs.setPastEventDisplay(mode) } + viewModelScope.launch { + prefs.setPastEventDisplay(mode) + // The agenda widget honours this setting too, so push it into each + // instance's Glance state and recompose — same reliable-update path as + // setAgendaWidgetRange (updateAll alone won't re-run the data preamble). + widgetRefreshMutex.withLock { + val manager = GlanceAppWidgetManager(appContext) + manager.getGlanceIds(AgendaWidget::class.java).forEach { id -> + updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name } + } + AgendaWidget().updateAll(appContext) + } + } } fun setDimCompletedEvents(enabled: Boolean) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 468939b..cbce878 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventInstance @@ -24,6 +25,7 @@ import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import java.util.Locale import kotlin.time.Clock +import kotlin.time.Instant /** * How far either side of today the month widget pre-loads. The displayed month @@ -52,6 +54,8 @@ sealed interface AgendaWidgetData { * range is sliced from this in the composition (read reactively from * Glance state), so a range change is pure recomposition rather than a * flaky widget session restart — the same approach the month widget uses. + * Kept unfiltered: hiding/dimming finished events also happens reactively + * in the composition, against [now], for the same reliable-update reason. */ val days: List, /** Resolved clock convention for event time labels (the time-format pref). */ @@ -60,6 +64,10 @@ sealed interface AgendaWidgetData { val weekStart: DayOfWeek, /** Saved range pref — the fallback when an instance has no Glance state yet. */ val savedRange: AgendaRange, + /** Saved past-event display pref — the fallback before Glance state is set. */ + val savedPastDisplay: PastEventDisplay, + /** Snapshot instant the data was read at, for "has this event ended?" tests. */ + val now: Instant, ) : AgendaWidgetData } @@ -108,6 +116,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val ep = widgetEntryPoint() val prefs = ep.settingsPrefs() val savedRange = prefs.agendaWidgetRange.first() + val savedPastDisplay = prefs.pastEventDisplay.first() val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) // Load the widest selectable window once; the displayed range is sliced in // the composition from Glance state, so changing the range is a plain @@ -122,6 +131,8 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { is24Hour = is24Hour, weekStart = weekStart, savedRange = savedRange, + savedPastDisplay = savedPastDisplay, + now = Clock.System.now(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 4d5c77d..19c06c5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -43,11 +43,15 @@ import androidx.glance.text.Text import androidx.glance.text.TextStyle import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.widget.AgendaWidgetData @@ -79,6 +83,14 @@ import java.util.Locale */ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range") +/** + * Per-instance Glance state key holding the past-event display mode (as + * [PastEventDisplay.name]). Read reactively in the composition for the same + * reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live + * widget without depending on the `provideGlance` preamble re-running. + */ +internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display") + class AgendaWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition @@ -125,11 +137,26 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { // to the saved pref for a freshly placed widget), then the wide // pre-loaded window is sliced to it — pure recomposition. val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange) + val pastDisplay = + parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay) val rangeEnd = data.today.plus( range.dayCount(data.today, data.weekStart) - 1, DateTimeUnit.DAY, ) - val visibleDays = data.days.filter { it.date <= rangeEnd } + // Slice to the chosen range, then drop finished events (and any day + // they leave empty) when the mode is Hide — so "Upcoming" really is. + val visibleDays = data.days + .filter { it.date <= rangeEnd } + .let { days -> + if (pastDisplay == PastEventDisplay.HIDE) { + days.mapNotNull { day -> + val remaining = day.events.filterNot { it.hasEnded(data.now) } + if (remaining.isEmpty()) null else day.copy(events = remaining) + } + } else { + days + } + } if (visibleDays.isEmpty()) { WidgetMessage(R.string.agenda_empty_title) } else { @@ -143,7 +170,13 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { items(rows.size) { index -> when (val row = rows[index]) { is AgendaRow.Header -> DayHeaderRow(row.date, row.today) - is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour) + is AgendaRow.Event -> EventRow( + event = row.event, + dark = dark, + is24Hour = data.is24Hour, + dimmed = pastDisplay == PastEventDisplay.DIM && + row.event.hasEnded(data.now), + ) } } } @@ -224,9 +257,15 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) { } @Composable -private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) { +private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) { val context = androidx.glance.LocalContext.current val title = event.title.ifBlank { context.getString(R.string.event_untitled) } + // Glance has no generic alpha modifier, so dim by fading the colour stripe and + // dropping both text lines to the lower-emphasis on-surface-variant tone. + val stripeColor = pastelize(event.color, dark).let { + if (dimmed) it.copy(alpha = EventDimAlpha) else it + } + val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface Row( modifier = GlanceModifier .fillMaxWidth() @@ -249,14 +288,14 @@ private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) { .width(5.dp) .height(36.dp) .cornerRadius(3.dp) - .background(pastelize(event.color, dark)), + .background(stripeColor), ) {} Spacer(GlanceModifier.width(10.dp)) Column(modifier = GlanceModifier.defaultWeight()) { Text( text = title, maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp), + style = TextStyle(color = titleColor, fontSize = 14.sp), ) Text( text = eventTimeSummary(context, event, is24Hour), -- 2.49.1 From e1f79df67a6dfb3a229b35f0cfb0bd06de13a096 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 22:33:58 +0200 Subject: [PATCH 7/7] release: cut v2.12.0 changelog and fastlane notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the accumulated [Unreleased] section under [2.12.0] — 2026-06-28 and regenerate fastlane/.../changelogs/21200.txt from it (scripts/ sync_changelog_to_fastlane.sh), so the official F-Droid listing and the Gitea release notes both cover the release's two changes: the past-event display settings (agenda screen + widget, month/week dimming) and the overlapping-event title wrapping. versionName is already 2.12.0/21200. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- fastlane/metadata/android/en-US/changelogs/21200.txt | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ee6d1..0ab8105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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] +## [2.12.0] — 2026-06-28 ### Added - See past events your way. Two new settings change how events that have already diff --git a/fastlane/metadata/android/en-US/changelogs/21200.txt b/fastlane/metadata/android/en-US/changelogs/21200.txt index c7dc64e..dfaebe2 100644 --- a/fastlane/metadata/android/en-US/changelogs/21200.txt +++ b/fastlane/metadata/android/en-US/changelogs/21200.txt @@ -1,3 +1,14 @@ +### 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 — both in the app and the home-screen "Upcoming" widget — 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 lists + update on their own as the day goes on. Thanks to @ptab for the suggestion + ([#12]). + ### Changed - Readable titles on overlapping events. In the week and day views, events that overlap split a day into slim columns where the title used to be clipped to a @@ -5,3 +16,4 @@ fit, and the time is hidden on those narrow blocks so the name can fill the space — so you can tell events apart without opening each one. Thanks to @ptab for the suggestion ([#13]). + -- 2.49.1