From add88fbadfd2a2dff7447f10b3ffc28d43a23cf7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 14:15:46 +0200 Subject: [PATCH 01/13] feat(agenda): limit how far ahead the agenda screen and widget show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add independent agendaScreenRange and agendaWidgetRange preferences, each a rolling window: 1 day / 1 week / 1 month / custom 1–365 days (default Month). Rolling (not calendar-aligned) so the span never degenerates near a period boundary. The in-app Agenda screen and the agenda widget each read their own setting, configured via a new AgendaRangePicker with an inline custom-days editor. Closes #4 (Codeberg) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 27 ++++ .../calendula/ui/agenda/AgendaRange.kt | 54 ++++++++ .../calendula/ui/agenda/AgendaViewModel.kt | 16 ++- .../calendula/ui/common/Picker.kt | 126 ++++++++++++++++++ .../calendula/ui/settings/SettingsScreen.kt | 34 ++++- .../calendula/ui/settings/SettingsUiState.kt | 5 + .../ui/settings/SettingsViewModel.kt | 27 +++- .../calendula/widget/WidgetData.kt | 10 +- app/src/main/res/values-de/strings.xml | 11 ++ app/src/main/res/values/strings.xml | 11 ++ .../calendula/data/prefs/SettingsPrefsTest.kt | 13 ++ .../calendula/ui/agenda/AgendaRangeTest.kt | 50 +++++++ 12 files changed, 368 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt 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 2b3cb82..aaa9f0e 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 @@ -7,6 +7,9 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -70,6 +73,28 @@ class SettingsPrefs @Inject constructor( store.edit { it[WEEK_START_KEY] = pref.name } } + /** + * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to + * [AgendaRange.Month] — a month of upcoming events. Independent of the + * widget's [agendaWidgetRange]. + */ + val agendaScreenRange: Flow = store.data.map { prefs -> + parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month) + } + + suspend fun setAgendaScreenRange(range: AgendaRange) { + store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() } + } + + /** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */ + val agendaWidgetRange: Flow = store.data.map { prefs -> + parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month) + } + + suspend fun setAgendaWidgetRange(range: AgendaRange) { + store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() } + } + /** * The calendar view the app opens on (M1). Defaults to [CalendarView.Week] — * the historical hard-coded startup view — so existing users see no change @@ -259,6 +284,8 @@ class SettingsPrefs @Inject constructor( internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val WEEK_START_KEY = stringPreferencesKey("week_start") + internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") + internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt new file mode 100644 index 0000000..72106c4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -0,0 +1,54 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +/** + * How far ahead the agenda (screen or widget) shows events, as a rolling window + * starting at today. [Day]/[Week]/[Month] are fixed 1/7/30-day windows; [Custom] + * is an arbitrary day count. Rolling (not calendar-aligned) windows so the span + * never degenerates near a week/month boundary. + */ +sealed interface AgendaRange { + data object Day : AgendaRange + data object Week : AgendaRange + data object Month : AgendaRange + data class Custom(val days: Int) : AgendaRange + + companion object { + /** Allowed bounds for a [Custom] day count. */ + const val MIN_CUSTOM_DAYS = 1 + const val MAX_CUSTOM_DAYS = 365 + } +} + +/** Inclusive number of days the window spans, starting at (and including) today. */ +fun AgendaRange.dayCount(): Int = when (this) { + AgendaRange.Day -> 1 + AgendaRange.Week -> 7 + AgendaRange.Month -> 30 + is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS) +} + +/** Stored representation: a fixed token, or `CUSTOM:`. */ +fun AgendaRange.storageValue(): String = when (this) { + AgendaRange.Day -> "DAY" + AgendaRange.Week -> "WEEK" + AgendaRange.Month -> "MONTH" + is AgendaRange.Custom -> "$CUSTOM_PREFIX$days" +} + +/** Parse a stored value; unknown/garbage falls back to [default]. */ +fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when { + stored == "DAY" -> AgendaRange.Day + stored == "WEEK" -> AgendaRange.Week + stored == "MONTH" -> AgendaRange.Month + stored != null && stored.startsWith(CUSTOM_PREFIX) -> { + val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull() + if (days != null) { + AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)) + } else { + default + } + } + else -> default +} + +private const val CUSTOM_PREFIX = "CUSTOM:" 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 226938d..65f2f1a 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.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -30,16 +31,16 @@ import kotlin.time.Clock import kotlin.time.Instant import javax.inject.Inject -/** How far ahead the agenda loads events from its anchor day. */ -internal const val AGENDA_WINDOW_DAYS = 60 - @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class AgendaViewModel @Inject constructor( private val repository: CalendarRepository, + settingsPrefs: SettingsPrefs, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { + private val screenRange = settingsPrefs.agendaScreenRange + private val zone = TimeZone.currentSystemDefault() private val todayDate: LocalDate @@ -48,12 +49,13 @@ class AgendaViewModel @Inject constructor( private val _anchor = MutableStateFlow(todayDate) val anchor: StateFlow = _anchor - val state: StateFlow = _anchor - .flatMapLatest { anchor -> - val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone) + val state: StateFlow = + combine(_anchor, screenRange) { anchor, range -> anchor to range } + .flatMapLatest { (anchor, range) -> + val window = agendaRange(anchor, range.dayCount() - 1, zone) combine( repository.calendars(), - repository.instances(range), + repository.instances(window), ) { calendars, instances -> buildState(anchor, calendars, instances) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index d5d72d2..afbeeed 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -37,6 +38,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange /** * Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that @@ -269,6 +271,130 @@ private fun SelectedCheck() { ) } +/** + * Agenda-range picker, full-screen: the fixed 1 day / 1 week / 1 month windows + * as grouped rows, plus a "Custom" row that expands an inline day-count editor + * (1–365). Mirrors [ReminderDefaultPicker]'s custom-expand pattern. + */ +@Composable +fun AgendaRangePicker( + title: String, + selected: AgendaRange, + onSelect: (AgendaRange) -> Unit, + onDismiss: () -> Unit, +) { + val presets = listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month) + val customSelected = selected is AgendaRange.Custom + val rowCount = presets.size + 1 // + the custom row + + var customExpanded by rememberSaveable { mutableStateOf(false) } + var amountText by rememberSaveable { + mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "") + } + + FullScreenPicker(title = title, onDismiss = onDismiss) { + presets.forEachIndexed { index, option -> + val isSelected = option == selected + GroupedRow( + title = agendaRangeLabel(option), + position = positionOf(index, rowCount), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { + onSelect(option) + onDismiss() + }, + ) + } + GroupedRow( + title = if (customSelected) { + agendaRangeLabel(selected) + } else { + stringResource(R.string.agenda_range_custom) + }, + position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount), + selected = customSelected, + trailing = if (customSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { customExpanded = !customExpanded }, + ) + AnimatedVisibility( + visible = customExpanded, + enter = calendarExpandEnter(), + exit = calendarCollapseExit(), + ) { + CustomDaysEditor( + amountText = amountText, + onAmountChange = { amountText = it }, + onConfirm = { days -> + onSelect(AgendaRange.Custom(days)) + onDismiss() + }, + ) + } + } +} + +/** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */ +@Composable +private fun CustomDaysEditor( + amountText: String, + onAmountChange: (String) -> Unit, + onConfirm: (Int) -> Unit, +) { + val days = amountText.toIntOrNull() + ?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS } + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DialogAmountField( + value = amountText, + onValueChange = onAmountChange, + placeholder = "30", + ) + Spacer(Modifier.width(16.dp)) + Text( + text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) } + ?: stringResource(R.string.agenda_range_custom_hint), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(16.dp)) + FilledTonalButton( + onClick = { days?.let(onConfirm) }, + enabled = days != null, + ) { + Text(stringResource(R.string.reminder_custom_set)) + } + } + } +} + +/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */ +@Composable +fun agendaRangeLabel(range: AgendaRange): String = when (range) { + AgendaRange.Day -> stringResource(R.string.agenda_range_day) + AgendaRange.Week -> stringResource(R.string.agenda_range_week) + AgendaRange.Month -> stringResource(R.string.agenda_range_month) + is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days) +} + @Composable private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) { CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) 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 21a8f7a..fba3edb 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 @@ -92,6 +92,8 @@ import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter +import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -425,6 +427,8 @@ private fun AppearanceScreen( var showTheme by remember { mutableStateOf(false) } var showWeekStart by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) } + var showAgendaScreenRange by remember { mutableStateOf(false) } + var showAgendaWidgetRange by remember { mutableStateOf(false) } CollapsingScaffold( title = stringResource(R.string.settings_section_appearance), @@ -466,9 +470,21 @@ private fun AppearanceScreen( GroupedRow( title = stringResource(R.string.settings_week_start), summary = weekStartLabel(state.weekStart), - position = Position.Bottom, + position = Position.Middle, onClick = { showWeekStart = true }, ) + GroupedRow( + title = stringResource(R.string.settings_agenda_range), + summary = agendaRangeLabel(state.agendaScreenRange), + position = Position.Middle, + onClick = { showAgendaScreenRange = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_agenda_widget_range), + summary = agendaRangeLabel(state.agendaWidgetRange), + position = Position.Bottom, + onClick = { showAgendaWidgetRange = true }, + ) } if (showTheme) { @@ -491,6 +507,22 @@ private fun AppearanceScreen( onDismiss = { showWeekStart = false }, ) } + if (showAgendaScreenRange) { + AgendaRangePicker( + title = stringResource(R.string.settings_agenda_range), + selected = state.agendaScreenRange, + onSelect = viewModel::setAgendaScreenRange, + onDismiss = { showAgendaScreenRange = false }, + ) + } + if (showAgendaWidgetRange) { + AgendaRangePicker( + title = stringResource(R.string.settings_agenda_widget_range), + selected = state.agendaWidgetRange, + onSelect = viewModel::setAgendaWidgetRange, + onDismiss = { showAgendaWidgetRange = false }, + ) + } if (showDefaultView) { OptionPicker( title = stringResource(R.string.settings_default_view), 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 1dac3bb..f45ad64 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 @@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView /** @@ -18,6 +19,10 @@ data class SettingsUiState( val dynamicColor: Boolean = true, val dynamicColorAvailable: Boolean = true, val weekStart: WeekStartPref = WeekStartPref.AUTO, + /** 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). */ + val agendaWidgetRange: AgendaRange = AgendaRange.Month, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** Optional event-form fields shown by default (rest behind "more fields"). */ 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 45a3160..c1e1ccf 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 de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -73,10 +74,16 @@ class SettingsViewModel @Inject constructor( ) { overrides, allDayOverrides, calendars -> ReminderOverrides(overrides, allDayOverrides, calendars) }, - prefs.defaultView, - ) { base, defaults, overrides, defaultView -> + combine( + prefs.defaultView, + prefs.agendaScreenRange, + prefs.agendaWidgetRange, + ) { view, screenRange, widgetRange -> ViewSettings(view, screenRange, widgetRange) }, + ) { base, defaults, overrides, views -> base.copy( - defaultView = defaultView, + defaultView = views.defaultView, + agendaScreenRange = views.agendaScreenRange, + agendaWidgetRange = views.agendaWidgetRange, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -106,6 +113,12 @@ class SettingsViewModel @Inject constructor( val calendars: List, ) + private data class ViewSettings( + val defaultView: CalendarView, + val agendaScreenRange: AgendaRange, + val agendaWidgetRange: AgendaRange, + ) + fun setThemeMode(mode: ThemeMode) { viewModelScope.launch { prefs.setThemeMode(mode) } } @@ -118,6 +131,14 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setWeekStart(pref) } } + fun setAgendaScreenRange(range: AgendaRange) { + viewModelScope.launch { prefs.setAgendaScreenRange(range) } + } + + fun setAgendaWidgetRange(range: AgendaRange) { + viewModelScope.launch { prefs.setAgendaWidgetRange(range) } + } + fun setDefaultView(view: CalendarView) { viewModelScope.launch { prefs.setDefaultView(view) } } 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 854a5e0..5cea8c8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay import de.jeanlucmakiola.calendula.ui.agenda.agendaRange +import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays import kotlinx.coroutines.flow.first import kotlinx.datetime.DateTimeUnit @@ -23,9 +24,6 @@ import kotlinx.datetime.toLocalDateTime import java.util.Locale import kotlin.time.Clock -/** How far ahead the agenda widget loads (a month of upcoming events). */ -private const val AGENDA_WIDGET_DAYS = 30 - /** * How far either side of today the month widget pre-loads. The displayed month * is chosen reactively in the composition, so one wide read covers ~13 months of @@ -91,8 +89,10 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() val anchor = today(zone) - val repo = widgetEntryPoint().calendarRepository() - val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first() + val ep = widgetEntryPoint() + val range = ep.settingsPrefs().agendaWidgetRange.first() + val window = agendaRange(anchor, range.dayCount() - 1, zone) + val instances = ep.calendarRepository().instances(window).first() return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone)) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aa24a30..36c1f53 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -278,6 +278,17 @@ Automatisch Montag Sonntag + Agenda-Zeitraum + Agenda-Widget-Zeitraum + 1 Tag + 1 Woche + 1 Monat + Benutzerdefiniert… + Tage + + %d Tag + %d Tage + Termin-Formular Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\" Farben auf nicht unterstützten Kalendern erlauben diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 644286f..c0a7b8f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,6 +271,17 @@ Automatic Monday Sunday + Agenda range + Agenda widget range + 1 day + 1 week + 1 month + Custom… + Days + + %d day + %d days + New event form Fields shown by default — everything else sits behind \"More fields\" Allow colors on unsupported calendars diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index dee5554..652e765 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -49,6 +50,18 @@ class SettingsPrefsTest { assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY) } + @Test + fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month) + assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month) + + prefs.setAgendaScreenRange(AgendaRange.Custom(14)) + prefs.setAgendaWidgetRange(AgendaRange.Week) + assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14)) + assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week) + } + @Test fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt new file mode 100644 index 0000000..57d3db7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt @@ -0,0 +1,50 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class AgendaRangeTest { + + @Test + fun `fixed ranges have 1, 7, 30 day counts`() { + assertThat(AgendaRange.Day.dayCount()).isEqualTo(1) + assertThat(AgendaRange.Week.dayCount()).isEqualTo(7) + assertThat(AgendaRange.Month.dayCount()).isEqualTo(30) + } + + @Test + fun `custom day count is clamped to bounds`() { + assertThat(AgendaRange.Custom(45).dayCount()).isEqualTo(45) + assertThat(AgendaRange.Custom(0).dayCount()).isEqualTo(AgendaRange.MIN_CUSTOM_DAYS) + assertThat(AgendaRange.Custom(9_999).dayCount()).isEqualTo(AgendaRange.MAX_CUSTOM_DAYS) + } + + @Test + fun `fixed ranges round-trip through storage`() { + listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month).forEach { range -> + assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day)) + .isEqualTo(range) + } + } + + @Test + fun `custom range round-trips through storage`() { + val range = AgendaRange.Custom(14) + assertThat(range.storageValue()).isEqualTo("CUSTOM:14") + assertThat(parseAgendaRange("CUSTOM:14", default = AgendaRange.Day)) + .isEqualTo(AgendaRange.Custom(14)) + } + + @Test + fun `garbage and null fall back to the default`() { + assertThat(parseAgendaRange(null, default = AgendaRange.Month)).isEqualTo(AgendaRange.Month) + assertThat(parseAgendaRange("YEAR", default = AgendaRange.Week)).isEqualTo(AgendaRange.Week) + assertThat(parseAgendaRange("CUSTOM:abc", default = AgendaRange.Day)).isEqualTo(AgendaRange.Day) + } + + @Test + fun `out-of-range custom value clamps on parse`() { + assertThat(parseAgendaRange("CUSTOM:9999", default = AgendaRange.Day)) + .isEqualTo(AgendaRange.Custom(AgendaRange.MAX_CUSTOM_DAYS)) + } +} From 766c2ffcf8ad3c11fd3ffe1fac1395dcb7c902bb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 14:25:59 +0200 Subject: [PATCH 02/13] feat(agenda): add calendar-aligned "this week"/"this month" ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend AgendaRange with calendar-aligned windows alongside the rolling ones: ThisWeek runs through the end of the current week (respecting the week-start preference — a Monday start means everything before next Monday), ThisMonth through the last day of the current month. dayCount now takes the anchor day and week-start; the agenda screen and widget resolve the week-start preference and pass it through. Rolling options relabelled ("Today", "Next 7 days", "Next 30 days") to read distinctly from the new calendar-aligned ones. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaRange.kt | 45 ++++++++++++++--- .../calendula/ui/agenda/AgendaViewModel.kt | 15 ++++-- .../calendula/ui/common/Picker.kt | 10 +++- .../calendula/widget/WidgetData.kt | 6 ++- app/src/main/res/values-de/strings.xml | 8 +-- app/src/main/res/values/strings.xml | 8 +-- .../calendula/ui/agenda/AgendaRangeTest.kt | 49 ++++++++++++++++--- 7 files changed, 114 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt index 72106c4..e1f3bee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -1,15 +1,26 @@ package de.jeanlucmakiola.calendula.ui.agenda +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import java.time.YearMonth + /** - * How far ahead the agenda (screen or widget) shows events, as a rolling window - * starting at today. [Day]/[Week]/[Month] are fixed 1/7/30-day windows; [Custom] - * is an arbitrary day count. Rolling (not calendar-aligned) windows so the span - * never degenerates near a week/month boundary. + * How far ahead the agenda (screen or widget) shows events, starting at today. + * + * Two flavours: + * - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days), + * [Month] (30 days), [Custom] (1–365 days). These never degenerate. + * - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs + * through the end of the current week (respecting the week-start preference — + * a Monday start means "everything before next Monday"); [ThisMonth] runs + * through the last day of the current month. These shrink as the period ends. */ sealed interface AgendaRange { data object Day : AgendaRange data object Week : AgendaRange data object Month : AgendaRange + data object ThisWeek : AgendaRange + data object ThisMonth : AgendaRange data class Custom(val days: Int) : AgendaRange companion object { @@ -19,11 +30,27 @@ sealed interface AgendaRange { } } -/** Inclusive number of days the window spans, starting at (and including) today. */ -fun AgendaRange.dayCount(): Int = when (this) { +private const val DAYS_PER_WEEK = 7 + +/** + * Inclusive number of days the window spans, starting at (and including) + * [anchor]. The calendar-aligned ranges depend on the anchor day and, for + * [AgendaRange.ThisWeek], the [weekStart] preference. + */ +fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) { AgendaRange.Day -> 1 - AgendaRange.Week -> 7 + AgendaRange.Week -> DAYS_PER_WEEK AgendaRange.Month -> 30 + AgendaRange.ThisWeek -> { + // Days elapsed since the week's first day (0 when today *is* the start), + // so the window is the rest of the week through the day before it repeats. + val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK + DAYS_PER_WEEK - sinceWeekStart + } + AgendaRange.ThisMonth -> { + val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth() + daysInMonth - anchor.day + 1 + } is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS) } @@ -32,6 +59,8 @@ fun AgendaRange.storageValue(): String = when (this) { AgendaRange.Day -> "DAY" AgendaRange.Week -> "WEEK" AgendaRange.Month -> "MONTH" + AgendaRange.ThisWeek -> "THIS_WEEK" + AgendaRange.ThisMonth -> "THIS_MONTH" is AgendaRange.Custom -> "$CUSTOM_PREFIX$days" } @@ -40,6 +69,8 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when stored == "DAY" -> AgendaRange.Day stored == "WEEK" -> AgendaRange.Week stored == "MONTH" -> AgendaRange.Month + stored == "THIS_WEEK" -> AgendaRange.ThisWeek + stored == "THIS_MONTH" -> AgendaRange.ThisMonth stored != null && stored.startsWith(CUSTOM_PREFIX) -> { val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull() if (days != null) { 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 65f2f1a..85a74c2 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 @@ -6,6 +6,7 @@ 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.SettingsPrefs +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -18,10 +19,12 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import java.util.Locale import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime import kotlinx.datetime.plus @@ -41,6 +44,10 @@ class AgendaViewModel @Inject constructor( private val screenRange = settingsPrefs.agendaScreenRange + // First day of the week, for the calendar-aligned "this week" range. + private val weekStartDay = settingsPrefs.weekStart + .map { it.resolveFirstDay(Locale.getDefault()) } + private val zone = TimeZone.currentSystemDefault() private val todayDate: LocalDate @@ -50,9 +57,11 @@ class AgendaViewModel @Inject constructor( val anchor: StateFlow = _anchor val state: StateFlow = - combine(_anchor, screenRange) { anchor, range -> anchor to range } - .flatMapLatest { (anchor, range) -> - val window = agendaRange(anchor, range.dayCount() - 1, zone) + combine(_anchor, screenRange, weekStartDay) { anchor, range, weekStart -> + Triple(anchor, range, weekStart) + } + .flatMapLatest { (anchor, range, weekStart) -> + val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) combine( repository.calendars(), repository.instances(window), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index afbeeed..4c92b41 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -283,7 +283,13 @@ fun AgendaRangePicker( onSelect: (AgendaRange) -> Unit, onDismiss: () -> Unit, ) { - val presets = listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month) + val presets = listOf( + AgendaRange.Day, + AgendaRange.ThisWeek, + AgendaRange.ThisMonth, + AgendaRange.Week, + AgendaRange.Month, + ) val customSelected = selected is AgendaRange.Custom val rowCount = presets.size + 1 // + the custom row @@ -390,6 +396,8 @@ private fun CustomDaysEditor( @Composable fun agendaRangeLabel(range: AgendaRange): String = when (range) { AgendaRange.Day -> stringResource(R.string.agenda_range_day) + AgendaRange.ThisWeek -> stringResource(R.string.agenda_range_this_week) + AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month) AgendaRange.Week -> stringResource(R.string.agenda_range_week) AgendaRange.Month -> stringResource(R.string.agenda_range_month) is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days) 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 5cea8c8..fd69f78 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -90,8 +90,10 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val zone = systemZone() val anchor = today(zone) val ep = widgetEntryPoint() - val range = ep.settingsPrefs().agendaWidgetRange.first() - val window = agendaRange(anchor, range.dayCount() - 1, zone) + val prefs = ep.settingsPrefs() + val range = prefs.agendaWidgetRange.first() + val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) + val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) val instances = ep.calendarRepository().instances(window).first() return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone)) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 36c1f53..70eda74 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -280,9 +280,11 @@ Sonntag Agenda-Zeitraum Agenda-Widget-Zeitraum - 1 Tag - 1 Woche - 1 Monat + Heute + Diese Woche + Dieser Monat + Nächste 7 Tage + Nächste 30 Tage Benutzerdefiniert… Tage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0a7b8f..ed53195 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,9 +273,11 @@ Sunday Agenda range Agenda widget range - 1 day - 1 week - 1 month + Today + This week + This month + Next 7 days + Next 30 days Custom… Days diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt index 57d3db7..be030b1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt @@ -1,27 +1,60 @@ package de.jeanlucmakiola.calendula.ui.agenda import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate import org.junit.jupiter.api.Test class AgendaRangeTest { + // A reference anchor: 2026-06-17 is a Wednesday. + private val wednesday = LocalDate(2026, 6, 17) + @Test - fun `fixed ranges have 1, 7, 30 day counts`() { - assertThat(AgendaRange.Day.dayCount()).isEqualTo(1) - assertThat(AgendaRange.Week.dayCount()).isEqualTo(7) - assertThat(AgendaRange.Month.dayCount()).isEqualTo(30) + fun `rolling ranges have fixed 1, 7, 30 day counts regardless of anchor`() { + assertThat(AgendaRange.Day.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(1) + assertThat(AgendaRange.Week.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(7) + assertThat(AgendaRange.Month.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(30) + } + + @Test + fun `this week runs through the day before the week repeats`() { + // Mon-start week: Wed → Wed,Thu,Fri,Sat,Sun = 5 days (everything before next Monday). + assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(5) + // On the first day of the week the whole 7 days remain. + val monday = LocalDate(2026, 6, 15) + assertThat(AgendaRange.ThisWeek.dayCount(monday, DayOfWeek.MONDAY)).isEqualTo(7) + // A Sunday-start week shifts the boundary: Wed → 4 days left (through Saturday). + assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.SUNDAY)).isEqualTo(4) + } + + @Test + fun `this month runs through the last day of the month`() { + // June has 30 days: the 17th leaves 14 days (17..30 inclusive). + assertThat(AgendaRange.ThisMonth.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(14) + // February 2026 (non-leap) has 28 days. + assertThat(AgendaRange.ThisMonth.dayCount(LocalDate(2026, 2, 20), DayOfWeek.MONDAY)) + .isEqualTo(9) } @Test fun `custom day count is clamped to bounds`() { - assertThat(AgendaRange.Custom(45).dayCount()).isEqualTo(45) - assertThat(AgendaRange.Custom(0).dayCount()).isEqualTo(AgendaRange.MIN_CUSTOM_DAYS) - assertThat(AgendaRange.Custom(9_999).dayCount()).isEqualTo(AgendaRange.MAX_CUSTOM_DAYS) + assertThat(AgendaRange.Custom(45).dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(45) + assertThat(AgendaRange.Custom(0).dayCount(wednesday, DayOfWeek.MONDAY)) + .isEqualTo(AgendaRange.MIN_CUSTOM_DAYS) + assertThat(AgendaRange.Custom(9_999).dayCount(wednesday, DayOfWeek.MONDAY)) + .isEqualTo(AgendaRange.MAX_CUSTOM_DAYS) } @Test fun `fixed ranges round-trip through storage`() { - listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month).forEach { range -> + listOf( + AgendaRange.Day, + AgendaRange.Week, + AgendaRange.Month, + AgendaRange.ThisWeek, + AgendaRange.ThisMonth, + ).forEach { range -> assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day)) .isEqualTo(range) } From b2bfdc0f4288da17b126ad3ba15ec5472f1939d0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 15:36:19 +0200 Subject: [PATCH 03/13] refactor(settings): regroup Appearance into theme / calendar / agenda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the uniform two-per-card grouping for meaningful sections: theme & colour (2), calendar — default view, week start, time format, hour lines (4), and agenda (2). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/settings/SettingsScreen.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 a3decae..449602e 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 @@ -470,7 +470,7 @@ private fun AppearanceScreen( Spacer(Modifier.height(16.dp)) - // Calendar layout + // Calendar — view, week, and timeline formatting GroupedRow( title = stringResource(R.string.settings_default_view), summary = stringResource(state.defaultView.labelRes), @@ -480,17 +480,13 @@ private fun AppearanceScreen( GroupedRow( title = stringResource(R.string.settings_week_start), summary = weekStartLabel(state.weekStart), - position = Position.Bottom, + position = Position.Middle, onClick = { showWeekStart = true }, ) - - Spacer(Modifier.height(16.dp)) - - // Timeline display GroupedRow( title = stringResource(R.string.settings_time_format), summary = timeFormatLabel(state.timeFormat), - position = Position.Top, + position = Position.Middle, onClick = { showTimeFormat = true }, ) GroupedRow( From 3cf1439850583c3258c7c365d68629473d4c3415 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 15:50:18 +0200 Subject: [PATCH 04/13] feat(agenda): bottom-left pill to override the range for the session Add a tonal pill in the agenda's bottom-left corner showing the current range; tapping it opens the existing AgendaRangePicker as a session-only override. The override lives in AgendaViewModel (in-memory), so it survives view switches and rotation but resets to the saved default when the app is relaunched. The pill fills with the primary container while an override is active, and a hint in the picker spells out the temporary nature. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 96 +++++++++++++++++-- .../calendula/ui/agenda/AgendaUiState.kt | 4 + .../calendula/ui/agenda/AgendaViewModel.kt | 48 ++++++++-- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 136 insertions(+), 14 deletions(-) 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 22e6f33..317d2a7 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 @@ -2,21 +2,25 @@ package de.jeanlucmakiola.calendula.ui.agenda import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search @@ -35,7 +39,10 @@ import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember 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.clip @@ -47,6 +54,8 @@ 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.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn @@ -91,11 +100,13 @@ fun AgendaScreen( val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() + var showRangePicker by remember { mutableStateOf(false) } val isOnToday = when (val s = state) { is AgendaUiState.Success -> s.anchor == s.today else -> true } + val successState = state as? AgendaUiState.Success ModalNavigationDrawer( drawerState = drawerState, @@ -138,13 +149,84 @@ fun AgendaScreen( ) }, ) { innerPadding -> - AgendaContent( - state = state, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), + Box(modifier = Modifier.fillMaxSize()) { + AgendaContent( + state = state, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + // A bottom-left pill to peek at a different range for this + // session, mirroring the FAB column on the right. + successState?.let { s -> + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(innerPadding) + .padding(16.dp), + ) + } + } + } + } + + if (showRangePicker) { + AgendaRangePicker( + title = stringResource(R.string.settings_agenda_range), + description = stringResource(R.string.agenda_range_override_hint), + selected = successState?.range ?: AgendaRange.Month, + onSelect = viewModel::setRangeOverride, + onDismiss = { showRangePicker = false }, + ) + } +} + +/** + * A compact tonal pill showing the agenda's current range. Tapping it opens the + * range picker as a session-only override. Filled with the primary container + * while an override is active, so the temporary state is obvious. + */ +@Composable +private fun AgendaRangePill( + range: AgendaRange, + isOverride: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val container = if (isOverride) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val content = if (isOverride) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + color = container, + contentColor = content, + shape = RoundedCornerShape(50), + modifier = modifier.clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Icon( + imageVector = Icons.Filled.DateRange, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = agendaRangeLabel(range), + style = MaterialTheme.typography.labelLarge, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 2ba5c3b..594b8f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -53,5 +53,9 @@ sealed interface AgendaUiState { val anchor: LocalDate, val today: LocalDate, val days: List, + /** The range currently in effect — the saved default or a session override. */ + val range: AgendaRange, + /** True when [range] is a temporary in-view override of the saved default. */ + val rangeIsOverride: Boolean, ) : AgendaUiState } 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 85a74c2..7b00397 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 @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import java.util.Locale @@ -56,17 +57,31 @@ class AgendaViewModel @Inject constructor( private val _anchor = MutableStateFlow(todayDate) val anchor: StateFlow = _anchor + // A transient, in-view override of the saved agenda range. Held in memory + // (not persisted), so it survives view switches and rotation within the + // session but resets to the saved default when the app is relaunched. + private val _rangeOverride = MutableStateFlow(null) + val state: StateFlow = - combine(_anchor, screenRange, weekStartDay) { anchor, range, weekStart -> - Triple(anchor, range, weekStart) + combine(_anchor, screenRange, _rangeOverride, weekStartDay) { anchor, default, override, weekStart -> + AgendaParams( + anchor = anchor, + range = override ?: default, + rangeIsOverride = override != null && override != default, + weekStart = weekStart, + ) } - .flatMapLatest { (anchor, range, weekStart) -> - val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) + .flatMapLatest { params -> + val window = agendaRange( + params.anchor, + params.range.dayCount(params.anchor, params.weekStart) - 1, + zone, + ) combine( repository.calendars(), repository.instances(window), ) { calendars, instances -> - buildState(anchor, calendars, instances) + buildState(params, calendars, instances) } } .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } @@ -86,16 +101,35 @@ class AgendaViewModel @Inject constructor( _anchor.value = date } + /** Temporarily override the agenda range for this session (the bottom-left pill). */ + fun setRangeOverride(range: AgendaRange) { + _rangeOverride.value = range + } + + private data class AgendaParams( + val anchor: LocalDate, + val range: AgendaRange, + val rangeIsOverride: Boolean, + val weekStart: DayOfWeek, + ) + private fun buildState( - anchor: LocalDate, + params: AgendaParams, calendars: List, instances: List, ): AgendaUiState { if (calendars.isEmpty()) { return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) } + val anchor = params.anchor val days = groupAgendaDays(anchor, instances, zone) - return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days) + return AgendaUiState.Success( + anchor = anchor, + today = todayDate, + days = days, + range = params.range, + rangeIsOverride = params.rangeIsOverride, + ) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3b5477b..a5907ef 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -293,6 +293,7 @@ Nächste 30 Tage Benutzerdefiniert… Tage + Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt. %d Tag %d Tage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74ca96e..dff94df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -286,6 +286,7 @@ Next 30 days Custom… Days + Just for now — resets to your saved range when you reopen Calendula. %d day %d days From 366059b0129a21c3b4a06dc491c257ea79b6fd05 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 15:59:56 +0200 Subject: [PATCH 05/13] feat(agenda): split range picker into two lists with a concrete header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group the range options into calendar-aligned (today / this week / this month) and rolling (next 7 / 30 days / custom) lists. Add a header showing the concrete span currently in effect — a single date for Today, the month and year for This month, otherwise a start–end span — so it's clear what the agenda is showing. The header appears only where a window is supplied (the agenda pill), not in Settings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaRange.kt | 26 ++++++ .../calendula/ui/agenda/AgendaScreen.kt | 4 + .../calendula/ui/agenda/AgendaUiState.kt | 2 + .../calendula/ui/agenda/AgendaViewModel.kt | 5 ++ .../calendula/ui/common/Picker.kt | 86 +++++++++++++------ app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 97 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt index e1f3bee..1ae2046 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -3,6 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale /** * How far ahead the agenda (screen or widget) shows events, starting at today. @@ -82,4 +85,27 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when else -> default } +/** + * The concrete span the range covers, starting at [start] through [end] + * (inclusive), for a human-readable header: + * - [AgendaRange.Day] → a single medium date ("27 Jun 2026") + * - [AgendaRange.ThisMonth] → month and year ("June 2026") + * - everything else → "start – end" ("27 Jun – 3 Jul 2026") + */ +fun agendaRangeWindowSummary( + range: AgendaRange, + start: LocalDate, + end: LocalDate, + locale: Locale, +): String { + val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day) + val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day) + val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) + return when (range) { + AgendaRange.Day -> medium.format(javaStart) + AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart) + else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}" + } +} + private const val CUSTOM_PREFIX = "CUSTOM:" 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 317d2a7..4b76191 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 @@ -176,12 +176,16 @@ fun AgendaScreen( } if (showRangePicker) { + val locale = currentLocale() AgendaRangePicker( title = stringResource(R.string.settings_agenda_range), description = stringResource(R.string.agenda_range_override_hint), selected = successState?.range ?: AgendaRange.Month, onSelect = viewModel::setRangeOverride, onDismiss = { showRangePicker = false }, + currentWindow = successState?.let { + agendaRangeWindowSummary(it.range, it.anchor, it.rangeEnd, locale) + }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 594b8f8..5101f75 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -57,5 +57,7 @@ sealed interface AgendaUiState { val range: AgendaRange, /** True when [range] is a temporary in-view override of the saved default. */ val rangeIsOverride: Boolean, + /** Last day the current [range] covers (inclusive), for the range header. */ + val rangeEnd: LocalDate, ) : AgendaUiState } 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 7b00397..ae82858 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 @@ -123,12 +123,17 @@ class AgendaViewModel @Inject constructor( } val anchor = params.anchor val days = groupAgendaDays(anchor, instances, zone) + val rangeEnd = anchor.plus( + params.range.dayCount(anchor, params.weekStart) - 1, + DateTimeUnit.DAY, + ) return AgendaUiState.Success( anchor = anchor, today = todayDate, days = days, range = params.range, rangeIsOverride = params.rangeIsOverride, + rangeEnd = rangeEnd, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index 78df630..e1f8d19 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -283,9 +284,11 @@ private fun SelectedCheck() { } /** - * Agenda-range picker, full-screen: the fixed 1 day / 1 week / 1 month windows - * as grouped rows, plus a "Custom" row that expands an inline day-count editor - * (1–365). Mirrors [ReminderDefaultPicker]'s custom-expand pattern. + * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned + * options (today / this week / this month) and the rolling windows (next 7 / 30 + * days), with a "Custom" row that expands an inline day-count editor (1–365). + * When [currentWindow] is given, a header shows the concrete span in effect. + * Mirrors [ReminderDefaultPicker]'s custom-expand pattern. */ @Composable fun AgendaRangePicker( @@ -294,40 +297,50 @@ fun AgendaRangePicker( selected: AgendaRange, onSelect: (AgendaRange) -> Unit, onDismiss: () -> Unit, + currentWindow: String? = null, ) { - val presets = listOf( - AgendaRange.Day, - AgendaRange.ThisWeek, - AgendaRange.ThisMonth, - AgendaRange.Week, - AgendaRange.Month, - ) + val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth) + val rolling = listOf(AgendaRange.Week, AgendaRange.Month) + val rollingRowCount = rolling.size + 1 // + the custom row val customSelected = selected is AgendaRange.Custom - val rowCount = presets.size + 1 // + the custom row var customExpanded by rememberSaveable { mutableStateOf(false) } var amountText by rememberSaveable { mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "") } + val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position -> + val isSelected = option == selected + GroupedRow( + title = agendaRangeLabel(option), + position = position, + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { + onSelect(option) + onDismiss() + }, + ) + } + FullScreenPicker(title = title, onDismiss = onDismiss) { + if (currentWindow != null) AgendaRangeHeader(currentWindow) PickerDescription(description) - presets.forEachIndexed { index, option -> - val isSelected = option == selected - GroupedRow( - title = agendaRangeLabel(option), - position = positionOf(index, rowCount), - selected = isSelected, - trailing = if (isSelected) { - { SelectedCheck() } - } else { - null - }, - onClick = { - onSelect(option) - onDismiss() - }, - ) + + // Calendar-aligned windows. + calendarAligned.forEachIndexed { index, option -> + rangeRow(option, positionOf(index, calendarAligned.size)) + } + + Spacer(Modifier.height(12.dp)) + + // Rolling windows + custom. + rolling.forEachIndexed { index, option -> + rangeRow(option, positionOf(index, rollingRowCount)) } GroupedRow( title = if (customSelected) { @@ -335,7 +348,7 @@ fun AgendaRangePicker( } else { stringResource(R.string.agenda_range_custom) }, - position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount), + position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount), selected = customSelected, trailing = if (customSelected) { { SelectedCheck() } @@ -361,6 +374,23 @@ fun AgendaRangePicker( } } +/** The "Showing all events for " header at the top of the range picker. */ +@Composable +private fun AgendaRangeHeader(window: String) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = stringResource(R.string.agenda_range_showing_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = window, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + /** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */ @Composable private fun CustomDaysEditor( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a5907ef..51d28c3 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -294,6 +294,7 @@ Benutzerdefiniert… Tage Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt. + Alle Termine für %d Tag %d Tage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dff94df..4cd9e56 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -287,6 +287,7 @@ Custom… Days Just for now — resets to your saved range when you reopen Calendula. + Showing all events for %d day %d days From 6ba7f38887369b727d8d326e1704efd9763c6661 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:08:23 +0200 Subject: [PATCH 06/13] feat(agenda): range header banner + toggles for banner and pill Move the "Showing all events for " line out of the picker and into the agenda view as an always-visible header above the list. Add two settings (Agenda group, both ON by default) to toggle the range header banner and the bottom-left range pill independently. The picker keeps its two-list grouping but no longer carries the header. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 20 +++++++ .../calendula/ui/agenda/AgendaScreen.kt | 57 +++++++++++++++---- .../calendula/ui/agenda/AgendaUiState.kt | 4 ++ .../calendula/ui/agenda/AgendaViewModel.kt | 25 ++++++-- .../calendula/ui/common/Picker.kt | 20 ------- .../calendula/ui/settings/SettingsScreen.kt | 26 ++++++++- .../calendula/ui/settings/SettingsUiState.kt | 4 ++ .../ui/settings/SettingsViewModel.kt | 16 +++++- app/src/main/res/values-de/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ 10 files changed, 142 insertions(+), 38 deletions(-) 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 99f1334..1cb6020 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 @@ -137,6 +137,24 @@ class SettingsPrefs @Inject constructor( store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() } } + /** Whether the agenda shows its range-header banner (v2.11). Default ON. */ + val agendaShowRangeBanner: Flow = store.data.map { prefs -> + prefs[AGENDA_SHOW_RANGE_BANNER_KEY] ?: true + } + + suspend fun setAgendaShowRangeBanner(enabled: Boolean) { + store.edit { it[AGENDA_SHOW_RANGE_BANNER_KEY] = enabled } + } + + /** Whether the agenda shows the bottom-left range pill (v2.11). Default ON. */ + val agendaShowRangePill: Flow = store.data.map { prefs -> + prefs[AGENDA_SHOW_RANGE_PILL_KEY] ?: true + } + + suspend fun setAgendaShowRangePill(enabled: Boolean) { + store.edit { it[AGENDA_SHOW_RANGE_PILL_KEY] = enabled } + } + /** * The calendar view the app opens on (M1). Defaults to [CalendarView.Week] — * the historical hard-coded startup view — so existing users see no change @@ -328,6 +346,8 @@ class SettingsPrefs @Inject constructor( internal val WEEK_START_KEY = stringPreferencesKey("week_start") internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") + internal val AGENDA_SHOW_RANGE_BANNER_KEY = booleanPreferencesKey("agenda_show_range_banner") + internal val AGENDA_SHOW_RANGE_PILL_KEY = booleanPreferencesKey("agenda_show_range_pill") internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") 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 4b76191..9f28a22 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 @@ -150,17 +150,27 @@ fun AgendaScreen( }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { - AgendaContent( - state = state, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, + Column( modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) + .fillMaxSize() + .padding(innerPadding), + ) { + // A header naming the concrete window currently shown. + successState?.takeIf { it.showRangeBanner }?.let { s -> + AgendaRangeBanner(range = s.range, start = s.anchor, end = s.rangeEnd) + } + AgendaContent( + state = state, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) + } // A bottom-left pill to peek at a different range for this // session, mirroring the FAB column on the right. - successState?.let { s -> + successState?.takeIf { it.showRangePill }?.let { s -> AgendaRangePill( range = s.range, isOverride = s.rangeIsOverride, @@ -176,16 +186,12 @@ fun AgendaScreen( } if (showRangePicker) { - val locale = currentLocale() AgendaRangePicker( title = stringResource(R.string.settings_agenda_range), description = stringResource(R.string.agenda_range_override_hint), selected = successState?.range ?: AgendaRange.Month, onSelect = viewModel::setRangeOverride, onDismiss = { showRangePicker = false }, - currentWindow = successState?.let { - agendaRangeWindowSummary(it.range, it.anchor, it.rangeEnd, locale) - }, ) } } @@ -236,6 +242,33 @@ private fun AgendaRangePill( } } +/** + * A header naming the concrete window currently shown, e.g. "Showing all events + * for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026". + */ +@Composable +private fun AgendaRangeBanner( + range: AgendaRange, + start: LocalDate, + end: LocalDate, + modifier: Modifier = Modifier, +) { + val locale = currentLocale() + val window = agendaRangeWindowSummary(range, start, end, locale) + Column(modifier = modifier.padding(horizontal = 28.dp, vertical = 8.dp)) { + Text( + text = stringResource(R.string.agenda_range_showing_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${agendaRangeLabel(range)}, $window", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + @Composable private fun AgendaContent( state: AgendaUiState, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 5101f75..0b8414c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -59,5 +59,9 @@ sealed interface AgendaUiState { val rangeIsOverride: Boolean, /** Last day the current [range] covers (inclusive), for the range header. */ val rangeEnd: LocalDate, + /** Whether to show the range header banner (settings toggle, on by default). */ + val showRangeBanner: Boolean, + /** Whether to show the bottom-left range pill (settings toggle, on by default). */ + val showRangePill: Boolean, ) : AgendaUiState } 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 ae82858..758e296 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 @@ -43,7 +43,12 @@ class AgendaViewModel @Inject constructor( @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { - private val screenRange = settingsPrefs.agendaScreenRange + // The saved agenda range plus the banner/pill visibility toggles. + private val agendaSettings = combine( + settingsPrefs.agendaScreenRange, + settingsPrefs.agendaShowRangeBanner, + settingsPrefs.agendaShowRangePill, + ) { range, showBanner, showPill -> AgendaSettings(range, showBanner, showPill) } // First day of the week, for the calendar-aligned "this week" range. private val weekStartDay = settingsPrefs.weekStart @@ -63,12 +68,14 @@ class AgendaViewModel @Inject constructor( private val _rangeOverride = MutableStateFlow(null) val state: StateFlow = - combine(_anchor, screenRange, _rangeOverride, weekStartDay) { anchor, default, override, weekStart -> + combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart -> AgendaParams( anchor = anchor, - range = override ?: default, - rangeIsOverride = override != null && override != default, + range = override ?: settings.range, + rangeIsOverride = override != null && override != settings.range, weekStart = weekStart, + showRangeBanner = settings.showBanner, + showRangePill = settings.showPill, ) } .flatMapLatest { params -> @@ -106,11 +113,19 @@ class AgendaViewModel @Inject constructor( _rangeOverride.value = range } + private data class AgendaSettings( + val range: AgendaRange, + val showBanner: Boolean, + val showPill: Boolean, + ) + private data class AgendaParams( val anchor: LocalDate, val range: AgendaRange, val rangeIsOverride: Boolean, val weekStart: DayOfWeek, + val showRangeBanner: Boolean, + val showRangePill: Boolean, ) private fun buildState( @@ -134,6 +149,8 @@ class AgendaViewModel @Inject constructor( range = params.range, rangeIsOverride = params.rangeIsOverride, rangeEnd = rangeEnd, + showRangeBanner = params.showRangeBanner, + showRangePill = params.showRangePill, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index e1f8d19..05e7ba8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -287,7 +287,6 @@ private fun SelectedCheck() { * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned * options (today / this week / this month) and the rolling windows (next 7 / 30 * days), with a "Custom" row that expands an inline day-count editor (1–365). - * When [currentWindow] is given, a header shows the concrete span in effect. * Mirrors [ReminderDefaultPicker]'s custom-expand pattern. */ @Composable @@ -297,7 +296,6 @@ fun AgendaRangePicker( selected: AgendaRange, onSelect: (AgendaRange) -> Unit, onDismiss: () -> Unit, - currentWindow: String? = null, ) { val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth) val rolling = listOf(AgendaRange.Week, AgendaRange.Month) @@ -328,7 +326,6 @@ fun AgendaRangePicker( } FullScreenPicker(title = title, onDismiss = onDismiss) { - if (currentWindow != null) AgendaRangeHeader(currentWindow) PickerDescription(description) // Calendar-aligned windows. @@ -374,23 +371,6 @@ fun AgendaRangePicker( } } -/** The "Showing all events for " header at the top of the range picker. */ -@Composable -private fun AgendaRangeHeader(window: String) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - text = stringResource(R.string.agenda_range_showing_label), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = window, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } -} - /** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */ @Composable private fun CustomDaysEditor( 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 449602e..de031ed 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 @@ -514,9 +514,33 @@ private fun AppearanceScreen( GroupedRow( title = stringResource(R.string.settings_agenda_widget_range), summary = agendaRangeLabel(state.agendaWidgetRange), - position = Position.Bottom, + position = Position.Middle, onClick = { showAgendaWidgetRange = true }, ) + GroupedRow( + title = stringResource(R.string.settings_agenda_range_title), + summary = stringResource(R.string.settings_agenda_range_title_hint), + position = Position.Middle, + trailing = { + Switch( + checked = state.agendaShowRangeBanner, + onCheckedChange = viewModel::setAgendaShowRangeBanner, + ) + }, + onClick = { viewModel.setAgendaShowRangeBanner(!state.agendaShowRangeBanner) }, + ) + GroupedRow( + title = stringResource(R.string.settings_agenda_range_button), + summary = stringResource(R.string.settings_agenda_range_button_hint), + position = Position.Bottom, + trailing = { + Switch( + checked = state.agendaShowRangePill, + onCheckedChange = viewModel::setAgendaShowRangePill, + ) + }, + onClick = { viewModel.setAgendaShowRangePill(!state.agendaShowRangePill) }, + ) } if (showTheme) { 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 3d7b657..fb1958e 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 @@ -28,6 +28,10 @@ data class SettingsUiState( val agendaScreenRange: AgendaRange = AgendaRange.Month, /** How far ahead the agenda widget shows events (v2.11). */ val agendaWidgetRange: AgendaRange = AgendaRange.Month, + /** Whether the agenda shows its range-header banner (v2.11). */ + val agendaShowRangeBanner: Boolean = true, + /** Whether the agenda shows the bottom-left range pill (v2.11). */ + val agendaShowRangePill: Boolean = true, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** Optional event-form fields shown by default (rest behind "more fields"). */ 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 bcd7e3e..740b566 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 @@ -84,13 +84,19 @@ class SettingsViewModel @Inject constructor( ) { view, screenRange, widgetRange, timeFormat, showHourLines -> ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines) }, - ) { base, defaults, overrides, views -> + combine( + prefs.agendaShowRangeBanner, + prefs.agendaShowRangePill, + ) { banner, pill -> banner to pill }, + ) { base, defaults, overrides, views, agendaToggles -> base.copy( defaultView = views.defaultView, agendaScreenRange = views.agendaScreenRange, agendaWidgetRange = views.agendaWidgetRange, timeFormat = views.timeFormat, showHourLines = views.showHourLines, + agendaShowRangeBanner = agendaToggles.first, + agendaShowRangePill = agendaToggles.second, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -148,6 +154,14 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setAgendaWidgetRange(range) } } + fun setAgendaShowRangeBanner(enabled: Boolean) { + viewModelScope.launch { prefs.setAgendaShowRangeBanner(enabled) } + } + + fun setAgendaShowRangePill(enabled: Boolean) { + viewModelScope.launch { prefs.setAgendaShowRangePill(enabled) } + } + fun setTimeFormat(pref: TimeFormatPref) { viewModelScope.launch { prefs.setTimeFormat(pref) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 51d28c3..9a8e1aa 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -286,6 +286,10 @@ Wie weit im Voraus die Agenda-Ansicht Termine anzeigt. Agenda-Widget-Zeitraum Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt. + Zeitraum-Titel + Eine Überschrift anzeigen, die den dargestellten Zeitraum benennt + Zeitraum-Schaltfläche + Eine Schaltfläche in der Agenda anzeigen, um den Zeitraum für die Sitzung zu wechseln Heute Diese Woche Dieser Monat diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4cd9e56..3cbb111 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -279,6 +279,10 @@ How far ahead the Agenda screen lists events. Agenda widget range How far ahead the agenda home-screen widget lists events. + Range title + Show a header naming the dates the agenda is showing + Range button + Show a button in the agenda to switch the range for the session Today This week This month From f8a2569831a24a4714a5d3e025232eb904b19c47 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:20:33 +0200 Subject: [PATCH 07/13] feat(agenda): warmer empty state + "upcoming" wording Reword the range header to "Showing all upcoming events for". Replace the dry empty state ("Nothing scheduled" + subtitle) with a single warmer line ("You're all caught up"), drop the subtitle, and switch the icon to a coffee cup. The empty title is shared with the agenda widget. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 11 ++--------- app/src/main/res/values-de/strings.xml | 5 ++--- app/src/main/res/values/strings.xml | 5 ++--- 3 files changed, 6 insertions(+), 15 deletions(-) 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 9f28a22..0a7d9b4 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 @@ -20,8 +20,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.DateRange -import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue @@ -376,7 +376,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( - imageVector = Icons.Filled.EventAvailable, + imageVector = Icons.Filled.Coffee, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(48.dp), @@ -387,13 +387,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, ) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(R.string.agenda_empty_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9a8e1aa..a1448d2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -230,8 +230,7 @@ Heute Heute Morgen - Nichts geplant - Anstehende Termine erscheinen hier. + Alles erledigt Suchen @@ -298,7 +297,7 @@ Benutzerdefiniert… Tage Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt. - Alle Termine für + Alle anstehenden Termine für %d Tag %d Tage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3cbb111..fed0707 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -231,8 +231,7 @@ Today Today Tomorrow - Nothing scheduled - Upcoming events will show up here. + You\'re all caught up Search @@ -291,7 +290,7 @@ Custom… Days Just for now — resets to your saved range when you reopen Calendula. - Showing all events for + Showing all upcoming events for %d day %d days From 6471c48528986c3547ee2da412025e128ea9968d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:22:33 +0200 Subject: [PATCH 08/13] refactor(agenda): put range header and pill on one bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the session range pill from the bottom-left corner onto the same top bar as the "Showing all upcoming events for …" header — header on the left, pill on the right, each still independently toggleable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/agenda/AgendaScreen.kt | 72 +++++++++++-------- 1 file changed, 41 insertions(+), 31 deletions(-) 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 0a7d9b4..7aabfda 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 @@ -149,38 +149,48 @@ fun AgendaScreen( ) }, ) { innerPadding -> - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - // A header naming the concrete window currently shown. - successState?.takeIf { it.showRangeBanner }?.let { s -> - AgendaRangeBanner(range = s.range, start = s.anchor, end = s.rangeEnd) + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // One bar at the top: the "showing …" header on the left and the + // session range pill on the right (each toggleable independently). + val s = successState + if (s != null && (s.showRangeBanner || s.showRangePill)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + ) { + if (s.showRangeBanner) { + AgendaRangeBanner( + range = s.range, + start = s.anchor, + end = s.rangeEnd, + modifier = Modifier.weight(1f), + ) + } else { + Spacer(Modifier.weight(1f)) + } + if (s.showRangePill) { + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + ) + } } - AgendaContent( - state = state, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) - } - // A bottom-left pill to peek at a different range for this - // session, mirroring the FAB column on the right. - successState?.takeIf { it.showRangePill }?.let { s -> - AgendaRangePill( - range = s.range, - isOverride = s.rangeIsOverride, - onClick = { showRangePicker = true }, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(innerPadding) - .padding(16.dp), - ) } + AgendaContent( + state = state, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) } } } @@ -255,7 +265,7 @@ private fun AgendaRangeBanner( ) { val locale = currentLocale() val window = agendaRangeWindowSummary(range, start, end, locale) - Column(modifier = modifier.padding(horizontal = 28.dp, vertical = 8.dp)) { + Column(modifier = modifier) { Text( text = stringResource(R.string.agenda_range_showing_label), style = MaterialTheme.typography.labelMedium, From 0f31981d26fa85f754fc274cec3b82181d04714b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:25:46 +0200 Subject: [PATCH 09/13] refactor(agenda): one toggle for the whole range bar Consolidate the separate "range title" and "range button" settings into a single "Range bar" toggle, now that the header and switcher live on one bar. On by default. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 25 +++++--------- .../calendula/ui/agenda/AgendaScreen.kt | 33 ++++++++----------- .../calendula/ui/agenda/AgendaUiState.kt | 6 ++-- .../calendula/ui/agenda/AgendaViewModel.kt | 19 ++++------- .../calendula/ui/settings/SettingsScreen.kt | 22 +++---------- .../calendula/ui/settings/SettingsUiState.kt | 6 ++-- .../ui/settings/SettingsViewModel.kt | 18 +++------- app/src/main/res/values-de/strings.xml | 6 ++-- app/src/main/res/values/strings.xml | 6 ++-- 9 files changed, 47 insertions(+), 94 deletions(-) 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 1cb6020..ef2c757 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 @@ -137,22 +137,16 @@ class SettingsPrefs @Inject constructor( store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() } } - /** Whether the agenda shows its range-header banner (v2.11). Default ON. */ - val agendaShowRangeBanner: Flow = store.data.map { prefs -> - prefs[AGENDA_SHOW_RANGE_BANNER_KEY] ?: true + /** + * Whether the agenda shows its top range bar — the "showing …" header and + * the session range switcher (v2.11). Default ON. + */ + val agendaShowRangeBar: Flow = store.data.map { prefs -> + prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true } - suspend fun setAgendaShowRangeBanner(enabled: Boolean) { - store.edit { it[AGENDA_SHOW_RANGE_BANNER_KEY] = enabled } - } - - /** Whether the agenda shows the bottom-left range pill (v2.11). Default ON. */ - val agendaShowRangePill: Flow = store.data.map { prefs -> - prefs[AGENDA_SHOW_RANGE_PILL_KEY] ?: true - } - - suspend fun setAgendaShowRangePill(enabled: Boolean) { - store.edit { it[AGENDA_SHOW_RANGE_PILL_KEY] = enabled } + suspend fun setAgendaShowRangeBar(enabled: Boolean) { + store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled } } /** @@ -346,8 +340,7 @@ class SettingsPrefs @Inject constructor( internal val WEEK_START_KEY = stringPreferencesKey("week_start") internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") - internal val AGENDA_SHOW_RANGE_BANNER_KEY = booleanPreferencesKey("agenda_show_range_banner") - internal val AGENDA_SHOW_RANGE_PILL_KEY = booleanPreferencesKey("agenda_show_range_pill") + 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 DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") 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 7aabfda..8c6073c 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 @@ -155,32 +155,25 @@ fun AgendaScreen( .padding(innerPadding), ) { // One bar at the top: the "showing …" header on the left and the - // session range pill on the right (each toggleable independently). - val s = successState - if (s != null && (s.showRangeBanner || s.showRangePill)) { + // session range switcher on the right (one settings toggle). + successState?.takeIf { it.showRangeBar }?.let { s -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), ) { - if (s.showRangeBanner) { - AgendaRangeBanner( - range = s.range, - start = s.anchor, - end = s.rangeEnd, - modifier = Modifier.weight(1f), - ) - } else { - Spacer(Modifier.weight(1f)) - } - if (s.showRangePill) { - AgendaRangePill( - range = s.range, - isOverride = s.rangeIsOverride, - onClick = { showRangePicker = true }, - ) - } + AgendaRangeBanner( + range = s.range, + start = s.anchor, + end = s.rangeEnd, + modifier = Modifier.weight(1f), + ) + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + ) } } AgendaContent( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 0b8414c..110a528 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -59,9 +59,7 @@ sealed interface AgendaUiState { val rangeIsOverride: Boolean, /** Last day the current [range] covers (inclusive), for the range header. */ val rangeEnd: LocalDate, - /** Whether to show the range header banner (settings toggle, on by default). */ - val showRangeBanner: Boolean, - /** Whether to show the bottom-left range pill (settings toggle, on by default). */ - val showRangePill: Boolean, + /** Whether to show the top range bar — header + switcher (toggle, on by default). */ + val showRangeBar: Boolean, ) : AgendaUiState } 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 758e296..f088e4e 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 @@ -43,12 +43,11 @@ class AgendaViewModel @Inject constructor( @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { - // The saved agenda range plus the banner/pill visibility toggles. + // The saved agenda range plus the range-bar visibility toggle. private val agendaSettings = combine( settingsPrefs.agendaScreenRange, - settingsPrefs.agendaShowRangeBanner, - settingsPrefs.agendaShowRangePill, - ) { range, showBanner, showPill -> AgendaSettings(range, showBanner, showPill) } + settingsPrefs.agendaShowRangeBar, + ) { range, showBar -> AgendaSettings(range, showBar) } // First day of the week, for the calendar-aligned "this week" range. private val weekStartDay = settingsPrefs.weekStart @@ -74,8 +73,7 @@ class AgendaViewModel @Inject constructor( range = override ?: settings.range, rangeIsOverride = override != null && override != settings.range, weekStart = weekStart, - showRangeBanner = settings.showBanner, - showRangePill = settings.showPill, + showRangeBar = settings.showBar, ) } .flatMapLatest { params -> @@ -115,8 +113,7 @@ class AgendaViewModel @Inject constructor( private data class AgendaSettings( val range: AgendaRange, - val showBanner: Boolean, - val showPill: Boolean, + val showBar: Boolean, ) private data class AgendaParams( @@ -124,8 +121,7 @@ class AgendaViewModel @Inject constructor( val range: AgendaRange, val rangeIsOverride: Boolean, val weekStart: DayOfWeek, - val showRangeBanner: Boolean, - val showRangePill: Boolean, + val showRangeBar: Boolean, ) private fun buildState( @@ -149,8 +145,7 @@ class AgendaViewModel @Inject constructor( range = params.range, rangeIsOverride = params.rangeIsOverride, rangeEnd = rangeEnd, - showRangeBanner = params.showRangeBanner, - showRangePill = params.showRangePill, + showRangeBar = params.showRangeBar, ) } } 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 de031ed..5dc6ef8 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 @@ -518,28 +518,16 @@ private fun AppearanceScreen( onClick = { showAgendaWidgetRange = true }, ) GroupedRow( - title = stringResource(R.string.settings_agenda_range_title), - summary = stringResource(R.string.settings_agenda_range_title_hint), - position = Position.Middle, - trailing = { - Switch( - checked = state.agendaShowRangeBanner, - onCheckedChange = viewModel::setAgendaShowRangeBanner, - ) - }, - onClick = { viewModel.setAgendaShowRangeBanner(!state.agendaShowRangeBanner) }, - ) - GroupedRow( - title = stringResource(R.string.settings_agenda_range_button), - summary = stringResource(R.string.settings_agenda_range_button_hint), + title = stringResource(R.string.settings_agenda_range_bar), + summary = stringResource(R.string.settings_agenda_range_bar_hint), position = Position.Bottom, trailing = { Switch( - checked = state.agendaShowRangePill, - onCheckedChange = viewModel::setAgendaShowRangePill, + checked = state.agendaShowRangeBar, + onCheckedChange = viewModel::setAgendaShowRangeBar, ) }, - onClick = { viewModel.setAgendaShowRangePill(!state.agendaShowRangePill) }, + onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) }, ) } 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 fb1958e..0507e6a 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 @@ -28,10 +28,8 @@ data class SettingsUiState( val agendaScreenRange: AgendaRange = AgendaRange.Month, /** How far ahead the agenda widget shows events (v2.11). */ val agendaWidgetRange: AgendaRange = AgendaRange.Month, - /** Whether the agenda shows its range-header banner (v2.11). */ - val agendaShowRangeBanner: Boolean = true, - /** Whether the agenda shows the bottom-left range pill (v2.11). */ - val agendaShowRangePill: Boolean = true, + /** Whether the agenda shows its top range bar — header + switcher (v2.11). */ + val agendaShowRangeBar: Boolean = true, /** The calendar view the app opens on, and the home of the view back stack (M1). */ val defaultView: CalendarView = CalendarView.Week, /** Optional event-form fields shown by default (rest behind "more fields"). */ 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 740b566..bc080ba 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 @@ -84,19 +84,15 @@ class SettingsViewModel @Inject constructor( ) { view, screenRange, widgetRange, timeFormat, showHourLines -> ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines) }, - combine( - prefs.agendaShowRangeBanner, - prefs.agendaShowRangePill, - ) { banner, pill -> banner to pill }, - ) { base, defaults, overrides, views, agendaToggles -> + prefs.agendaShowRangeBar, + ) { base, defaults, overrides, views, showRangeBar -> base.copy( defaultView = views.defaultView, agendaScreenRange = views.agendaScreenRange, agendaWidgetRange = views.agendaWidgetRange, timeFormat = views.timeFormat, showHourLines = views.showHourLines, - agendaShowRangeBanner = agendaToggles.first, - agendaShowRangePill = agendaToggles.second, + agendaShowRangeBar = showRangeBar, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -154,12 +150,8 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setAgendaWidgetRange(range) } } - fun setAgendaShowRangeBanner(enabled: Boolean) { - viewModelScope.launch { prefs.setAgendaShowRangeBanner(enabled) } - } - - fun setAgendaShowRangePill(enabled: Boolean) { - viewModelScope.launch { prefs.setAgendaShowRangePill(enabled) } + fun setAgendaShowRangeBar(enabled: Boolean) { + viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) } } fun setTimeFormat(pref: TimeFormatPref) { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a1448d2..348ba61 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -285,10 +285,8 @@ Wie weit im Voraus die Agenda-Ansicht Termine anzeigt. Agenda-Widget-Zeitraum Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt. - Zeitraum-Titel - Eine Überschrift anzeigen, die den dargestellten Zeitraum benennt - Zeitraum-Schaltfläche - Eine Schaltfläche in der Agenda anzeigen, um den Zeitraum für die Sitzung zu wechseln + Zeitraum-Leiste + Eine Leiste oben in der Agenda anzeigen, die den dargestellten Zeitraum benennt, mit einer Schaltfläche zum Wechseln des Zeitraums für die Sitzung Heute Diese Woche Dieser Monat diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fed0707..60c1872 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -278,10 +278,8 @@ How far ahead the Agenda screen lists events. Agenda widget range How far ahead the agenda home-screen widget lists events. - Range title - Show a header naming the dates the agenda is showing - Range button - Show a button in the agenda to switch the range for the session + Range bar + Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session Today This week This month From f27b0e269d8180db32f3e0b2bbd82be438cb7a14 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:30:27 +0200 Subject: [PATCH 10/13] fix(widget): refresh agenda widget when its range (or week start) changes The agenda widget only re-read agendaWidgetRange on the next data-change / midnight / periodic refresh (or re-placement), so a settings change appeared to do nothing until then. Push an updateAll from the settings setters: the agenda widget on an agenda-widget-range change, and both widgets on a week-start change (month weekday header + the agenda widget's "this week"). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/settings/SettingsViewModel.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 bc080ba..b55437a 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 @@ -1,9 +1,12 @@ package de.jeanlucmakiola.calendula.ui.settings +import android.content.Context import android.os.Build +import androidx.glance.appwidget.updateAll import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.SettingsPrefs @@ -14,6 +17,8 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget +import de.jeanlucmakiola.calendula.widget.month.MonthWidget import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -28,6 +33,7 @@ import javax.inject.Inject class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, repository: CalendarRepository, + @ApplicationContext private val appContext: Context, ) : ViewModel() { private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S @@ -139,7 +145,13 @@ class SettingsViewModel @Inject constructor( } fun setWeekStart(pref: WeekStartPref) { - viewModelScope.launch { prefs.setWeekStart(pref) } + viewModelScope.launch { + prefs.setWeekStart(pref) + // Both widgets depend on the week start (month weekday header; the + // agenda widget's "this week" range), so push a redraw. + AgendaWidget().updateAll(appContext) + MonthWidget().updateAll(appContext) + } } fun setAgendaScreenRange(range: AgendaRange) { @@ -147,7 +159,12 @@ class SettingsViewModel @Inject constructor( } fun setAgendaWidgetRange(range: AgendaRange) { - viewModelScope.launch { prefs.setAgendaWidgetRange(range) } + viewModelScope.launch { + prefs.setAgendaWidgetRange(range) + // Push the new range to the placed widget immediately, instead of + // waiting for the next data-change / midnight / periodic refresh. + AgendaWidget().updateAll(appContext) + } } fun setAgendaShowRangeBar(enabled: Boolean) { From cd703e9d540481963e284bb16b0c8d1dd2b0a2bc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:37:57 +0200 Subject: [PATCH 11/13] fix(widget): debounce widget refresh on pref change Refreshing the widget directly from the setter raced when a setting was flipped and flipped back quickly: two concurrent updateAll calls could coalesce around a stale read and leave the widget on the intermediate value. Observe the agenda-widget-range and week-start prefs instead and push a single debounced updateAll once changes settle, so the widget always converges to the final value. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/settings/SettingsViewModel.kt | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) 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 b55437a..bd1ff95 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 @@ -19,16 +19,25 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val WIDGET_REFRESH_DEBOUNCE_MS = 350L + +@OptIn(FlowPreview::class) @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, @@ -114,6 +123,31 @@ class SettingsViewModel @Inject constructor( initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable), ) + init { + // Push widget redraws when their backing prefs change, debounced so a + // rapid flip-and-flip-back settles to one update of the final value + // (two racing updateAll calls can otherwise leave the widget stale). + // drop(1) skips the value present at startup — nothing to refresh yet. + prefs.agendaWidgetRange + .drop(1) + .debounce(WIDGET_REFRESH_DEBOUNCE_MS) + .distinctUntilChanged() + .onEach { AgendaWidget().updateAll(appContext) } + .launchIn(viewModelScope) + + // Both widgets depend on the week start (month weekday header; the + // agenda widget's "this week" range). + prefs.weekStart + .drop(1) + .debounce(WIDGET_REFRESH_DEBOUNCE_MS) + .distinctUntilChanged() + .onEach { + AgendaWidget().updateAll(appContext) + MonthWidget().updateAll(appContext) + } + .launchIn(viewModelScope) + } + private data class ReminderDefaults( val allowColor: Boolean, val defaultReminder: Int?, @@ -145,13 +179,7 @@ class SettingsViewModel @Inject constructor( } fun setWeekStart(pref: WeekStartPref) { - viewModelScope.launch { - prefs.setWeekStart(pref) - // Both widgets depend on the week start (month weekday header; the - // agenda widget's "this week" range), so push a redraw. - AgendaWidget().updateAll(appContext) - MonthWidget().updateAll(appContext) - } + viewModelScope.launch { prefs.setWeekStart(pref) } } fun setAgendaScreenRange(range: AgendaRange) { @@ -159,12 +187,7 @@ class SettingsViewModel @Inject constructor( } fun setAgendaWidgetRange(range: AgendaRange) { - viewModelScope.launch { - prefs.setAgendaWidgetRange(range) - // Push the new range to the placed widget immediately, instead of - // waiting for the next data-change / midnight / periodic refresh. - AgendaWidget().updateAll(appContext) - } + viewModelScope.launch { prefs.setAgendaWidgetRange(range) } } fun setAgendaShowRangeBar(enabled: Boolean) { From bb92d020002e3d7709df320e998d3646c3d532b8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 16:44:52 +0200 Subject: [PATCH 12/13] fix(widget): refresh from setter, serialized with a mutex The debounced observer was unreliable: drop(1) plus the ViewModel lifecycle could swallow a change after re-entering the app, so a single range change sometimes didn't refresh the widget. Refresh directly from the settings setters instead (always fires on a real change), serialized through a mutex so a rapid flip-and-flip-back can't run two updateAll calls at once and strand the widget on the intermediate value. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/settings/SettingsViewModel.kt | 55 +++++++------------ 1 file changed, 20 insertions(+), 35 deletions(-) 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 bd1ff95..4adf344 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 @@ -19,25 +19,18 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject -private const val WIDGET_REFRESH_DEBOUNCE_MS = 350L - -@OptIn(FlowPreview::class) @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, @@ -123,30 +116,11 @@ class SettingsViewModel @Inject constructor( initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable), ) - init { - // Push widget redraws when their backing prefs change, debounced so a - // rapid flip-and-flip-back settles to one update of the final value - // (two racing updateAll calls can otherwise leave the widget stale). - // drop(1) skips the value present at startup — nothing to refresh yet. - prefs.agendaWidgetRange - .drop(1) - .debounce(WIDGET_REFRESH_DEBOUNCE_MS) - .distinctUntilChanged() - .onEach { AgendaWidget().updateAll(appContext) } - .launchIn(viewModelScope) - - // Both widgets depend on the week start (month weekday header; the - // agenda widget's "this week" range). - prefs.weekStart - .drop(1) - .debounce(WIDGET_REFRESH_DEBOUNCE_MS) - .distinctUntilChanged() - .onEach { - AgendaWidget().updateAll(appContext) - MonthWidget().updateAll(appContext) - } - .launchIn(viewModelScope) - } + // Serialises widget redraws so a rapid flip-and-flip-back can't run two + // updateAll calls at once — concurrent calls coalesce around a stale read + // and can leave the widget on the intermediate value. Held sequentially, + // the last call reads the final committed pref and renders it. + private val widgetRefreshMutex = Mutex() private data class ReminderDefaults( val allowColor: Boolean, @@ -179,7 +153,15 @@ class SettingsViewModel @Inject constructor( } fun setWeekStart(pref: WeekStartPref) { - viewModelScope.launch { prefs.setWeekStart(pref) } + viewModelScope.launch { + prefs.setWeekStart(pref) + // Both widgets depend on the week start (month weekday header; the + // agenda widget's "this week" range). + widgetRefreshMutex.withLock { + AgendaWidget().updateAll(appContext) + MonthWidget().updateAll(appContext) + } + } } fun setAgendaScreenRange(range: AgendaRange) { @@ -187,7 +169,10 @@ class SettingsViewModel @Inject constructor( } fun setAgendaWidgetRange(range: AgendaRange) { - viewModelScope.launch { prefs.setAgendaWidgetRange(range) } + viewModelScope.launch { + prefs.setAgendaWidgetRange(range) + widgetRefreshMutex.withLock { AgendaWidget().updateAll(appContext) } + } } fun setAgendaShowRangeBar(enabled: Boolean) { From c30a1535524235027cb9b3808f226686853f9b1e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 17:03:48 +0200 Subject: [PATCH 13/13] fix(widget): drive agenda range via reactive Glance state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agenda widget loaded and sliced its data in the provideGlance preamble and captured it as a non-reactive local. updateAll() reliably recomposes a live Glance session but does not reliably re-run that preamble, so a range change was redrawn against the stale slice (intermittently — only when no session was alive did it pick up the new range). This is the same platform limitation the month widget already works around. Mirror that pattern: load the widest selectable window once, store the range in per-instance Glance state (AGENDA_RANGE_KEY), read it reactively via currentState, and slice in the composition. The settings setter writes the state into each instance and recomposes, so a range change now reflects via plain recomposition regardless of session lifecycle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ISSUE_TEMPLATE/crash_report.md | 1 + .gitea/ISSUE_TEMPLATE/feature_request.md | 2 +- .gitea/ISSUE_TEMPLATE/question.md | 19 +++++++++++ .../ui/settings/SettingsViewModel.kt | 16 ++++++++- .../calendula/widget/WidgetData.kt | 21 ++++++++++-- .../calendula/widget/agenda/AgendaWidget.kt | 34 +++++++++++++++++-- 6 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 .gitea/ISSUE_TEMPLATE/question.md diff --git a/.gitea/ISSUE_TEMPLATE/crash_report.md b/.gitea/ISSUE_TEMPLATE/crash_report.md index c76b1b7..6828788 100644 --- a/.gitea/ISSUE_TEMPLATE/crash_report.md +++ b/.gitea/ISSUE_TEMPLATE/crash_report.md @@ -5,6 +5,7 @@ title: "Crash: " labels: - bug - crash + - priority:high --- + + +### Context +- Calendula version: +- Android version: +- Device: 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 4adf344..b9516a6 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 @@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.ui.settings import android.content.Context import android.os.Build +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.updateAll import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -16,7 +18,9 @@ import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource 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_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget import kotlinx.coroutines.flow.Flow @@ -171,7 +175,17 @@ class SettingsViewModel @Inject constructor( fun setAgendaWidgetRange(range: AgendaRange) { viewModelScope.launch { prefs.setAgendaWidgetRange(range) - widgetRefreshMutex.withLock { AgendaWidget().updateAll(appContext) } + widgetRefreshMutex.withLock { + // Push the range into each instance's Glance state and recompose. + // The widget reads it via currentState, so this reflects reliably + // even when updateAll only recomposes a live session (it does not + // re-run provideGlance's data preamble). + val manager = GlanceAppWidgetManager(appContext) + manager.getGlanceIds(AgendaWidget::class.java).forEach { id -> + updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() } + } + AgendaWidget().updateAll(appContext) + } } } 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 ac530cf..468939b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -8,8 +8,8 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.agendaRange -import de.jeanlucmakiola.calendula.ui.agenda.dayCount import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays import kotlinx.coroutines.flow.first import kotlinx.datetime.DateTimeUnit @@ -47,9 +47,19 @@ sealed interface AgendaWidgetData { data object NeedsPermission : AgendaWidgetData data class Ready( val today: LocalDate, + /** + * Days with events across the **widest** selectable window. The displayed + * 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. + */ val days: List, /** Resolved clock convention for event time labels (the time-format pref). */ val is24Hour: Boolean, + /** First day of the week, for the calendar-aligned "this week" range. */ + val weekStart: DayOfWeek, + /** Saved range pref — the fallback when an instance has no Glance state yet. */ + val savedRange: AgendaRange, ) : AgendaWidgetData } @@ -97,9 +107,12 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val anchor = today(zone) val ep = widgetEntryPoint() val prefs = ep.settingsPrefs() - val range = prefs.agendaWidgetRange.first() + val savedRange = prefs.agendaWidgetRange.first() val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) - val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) + // Load the widest selectable window once; the displayed range is sliced in + // the composition from Glance state, so changing the range is a plain + // recomposition and never depends on the widget session restarting. + val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone) val instances = ep.calendarRepository().instances(window).first() val is24Hour = prefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(this)) @@ -107,6 +120,8 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { today = anchor, days = groupAgendaDays(anchor, instances, zone), is24Hour = is24Hour, + weekStart = weekStart, + savedRange = savedRange, ) } 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 a2a28c3..4d5c77d 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 @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier @@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.updateAll import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column @@ -41,6 +44,9 @@ import androidx.glance.text.TextStyle import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +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.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.pastelize @@ -63,8 +69,20 @@ import java.util.Locale * of events grouped under day headers (the Google "Schedule" widget model). * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. */ +/** + * Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]). + * The range is read reactively in the composition ([currentState]) so a settings + * change is reflected by plain recomposition — `updateAll` does NOT reliably + * re-run the `provideGlance` preamble for a live widget session, so loading the + * range there and slicing it would silently keep the stale value (mirrors the + * month widget's MONTH_INDEX_KEY approach). + */ +internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range") + class AgendaWidget : GlanceAppWidget() { + override val stateDefinition = PreferencesGlanceStateDefinition + override suspend fun provideGlance(context: Context, id: GlanceId) { val data = context.loadAgendaWidgetData() val dark = (context.resources.configuration.uiMode and @@ -102,12 +120,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { Spacer(GlanceModifier.height(4.dp)) when (data) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission) - is AgendaWidgetData.Ready -> - if (data.days.isEmpty()) { + is AgendaWidgetData.Ready -> { + // Range read reactively from per-instance Glance state (falls back + // 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 rangeEnd = data.today.plus( + range.dayCount(data.today, data.weekStart) - 1, + DateTimeUnit.DAY, + ) + val visibleDays = data.days.filter { it.date <= rangeEnd } + if (visibleDays.isEmpty()) { WidgetMessage(R.string.agenda_empty_title) } else { val rows = buildList { - data.days.forEach { day -> + visibleDays.forEach { day -> add(AgendaRow.Header(day.date, data.today)) day.events.forEach { add(AgendaRow.Event(it)) } } @@ -121,6 +148,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { } } } + } } } }