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),