diff --git a/CHANGELOG.md b/CHANGELOG.md index b75b89a..0ab8105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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). +## [2.12.0] — 2026-06-28 + +### 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 + 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 @@ -706,3 +727,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 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/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..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 @@ -38,6 +38,19 @@ 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 } + +/** 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). @@ -116,6 +129,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 +442,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..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 @@ -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 @@ -20,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 @@ -99,6 +101,8 @@ class SettingsViewModel @Inject constructor( combine( prefs.agendaShowRangeBar, prefs.autofocusEventTitle, + prefs.pastEventDisplay, + prefs.dimCompletedEvents, ::MiscSettings, ), ) { base, defaults, overrides, views, misc -> @@ -110,6 +114,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 +162,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 +219,26 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setShowHourLines(enabled) } } + fun setPastEventDisplay(mode: PastEventDisplay) { + 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) { + 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..e91de43 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) @@ -692,6 +711,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 +732,7 @@ private fun DayColumnCard( private fun EventBlock( block: TimedBlock, dark: Boolean, + height: Dp, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -720,9 +741,26 @@ 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) + 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) @@ -732,18 +770,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), ) 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/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), 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 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..dfaebe2 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21200.txt @@ -0,0 +1,19 @@ +### 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 + 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]). +