From add88fbadfd2a2dff7447f10b3ffc28d43a23cf7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 27 Jun 2026 14:15:46 +0200 Subject: [PATCH] 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)) + } +}