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/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 19ad74b..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 @@ -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 @@ -112,6 +115,40 @@ class SettingsPrefs @Inject constructor( store.edit { it[SHOW_HOUR_LINES_KEY] = enabled } } + /** + * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to + * [AgendaRange.Month] — a month of upcoming events. Independent of the + * 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() } + } + + /** + * 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 setAgendaShowRangeBar(enabled: Boolean) { + store.edit { it[AGENDA_SHOW_RANGE_BAR_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 @@ -301,6 +338,9 @@ 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 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/AgendaRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt new file mode 100644 index 0000000..1ae2046 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -0,0 +1,111 @@ +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. + * + * 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 { + /** Allowed bounds for a [Custom] day count. */ + const val MIN_CUSTOM_DAYS = 1 + const val MAX_CUSTOM_DAYS = 365 + } +} + +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 -> 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) +} + +/** Stored representation: a fixed token, or `CUSTOM:`. */ +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" +} + +/** 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 == "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) { + AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)) + } else { + default + } + } + 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 22e6f33..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 @@ -2,22 +2,26 @@ 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.EventAvailable +import androidx.compose.material.icons.filled.Coffee +import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue @@ -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,18 +149,129 @@ fun AgendaScreen( ) }, ) { innerPadding -> - AgendaContent( - state = state, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, + Column( modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), + .fillMaxSize() + .padding(innerPadding), + ) { + // One bar at the top: the "showing …" header on the left and the + // 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), + ) { + 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( + state = state, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) + } + } + } + + 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, ) } } } +/** + * 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) { + 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, @@ -257,7 +379,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), @@ -268,13 +390,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/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 2ba5c3b..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 @@ -53,5 +53,13 @@ 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, + /** Last day the current [range] covers (inclusive), for the range header. */ + val rangeEnd: LocalDate, + /** 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 226938d..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 @@ -5,6 +5,8 @@ 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.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -17,10 +19,13 @@ 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.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import java.util.Locale import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime import kotlinx.datetime.plus @@ -30,16 +35,24 @@ 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() { + // The saved agenda range plus the range-bar visibility toggle. + private val agendaSettings = combine( + settingsPrefs.agendaScreenRange, + settingsPrefs.agendaShowRangeBar, + ) { range, showBar -> AgendaSettings(range, showBar) } + + // 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 @@ -48,14 +61,32 @@ 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) + // 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, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart -> + AgendaParams( + anchor = anchor, + range = override ?: settings.range, + rangeIsOverride = override != null && override != settings.range, + weekStart = weekStart, + showRangeBar = settings.showBar, + ) + } + .flatMapLatest { params -> + val window = agendaRange( + params.anchor, + params.range.dayCount(params.anchor, params.weekStart) - 1, + zone, + ) combine( repository.calendars(), - repository.instances(range), + repository.instances(window), ) { calendars, instances -> - buildState(anchor, calendars, instances) + buildState(params, calendars, instances) } } .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } @@ -75,16 +106,47 @@ 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 AgendaSettings( + val range: AgendaRange, + val showBar: Boolean, + ) + + private data class AgendaParams( + val anchor: LocalDate, + val range: AgendaRange, + val rangeIsOverride: Boolean, + val weekStart: DayOfWeek, + val showRangeBar: Boolean, + ) + 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) + 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, + showRangeBar = params.showRangeBar, + ) } } 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..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 @@ -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 @@ -30,6 +31,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 +39,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 @@ -260,6 +263,17 @@ private fun CustomReminderEditor( } } +/** A short explanatory paragraph shown under a picker's title, above the rows. */ +@Composable +private fun PickerDescription(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + @Composable private fun SelectedCheck() { Icon( @@ -269,6 +283,149 @@ 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). + * Mirrors [ReminderDefaultPicker]'s custom-expand pattern. + */ +@Composable +fun AgendaRangePicker( + title: String, + description: String, + selected: AgendaRange, + onSelect: (AgendaRange) -> Unit, + onDismiss: () -> Unit, +) { + 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 + + 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) { + PickerDescription(description) + + // 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) { + agendaRangeLabel(selected) + } else { + stringResource(R.string.agenda_range_custom) + }, + position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount), + 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.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) +} + @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 558aaa8..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 @@ -93,6 +93,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 @@ -430,23 +432,20 @@ private fun AppearanceScreen( var showWeekStart by remember { mutableStateOf(false) } var showTimeFormat 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), onBack = onBack, ) { + // Theme & colour GroupedRow( title = stringResource(R.string.settings_theme), summary = themeLabel(state.themeMode), position = Position.Top, onClick = { showTheme = true }, ) - GroupedRow( - title = stringResource(R.string.settings_default_view), - summary = stringResource(state.defaultView.labelRes), - position = Position.Middle, - onClick = { showDefaultView = true }, - ) GroupedRow( title = stringResource(R.string.settings_dynamic_color), summary = if (state.dynamicColorAvailable) { @@ -454,7 +453,7 @@ private fun AppearanceScreen( } else { stringResource(R.string.settings_dynamic_color_unavailable) }, - position = Position.Middle, + position = Position.Bottom, trailing = { Switch( checked = state.dynamicColor, @@ -468,6 +467,16 @@ private fun AppearanceScreen( null }, ) + + Spacer(Modifier.height(16.dp)) + + // Calendar — view, week, and timeline formatting + GroupedRow( + title = stringResource(R.string.settings_default_view), + summary = stringResource(state.defaultView.labelRes), + position = Position.Top, + onClick = { showDefaultView = true }, + ) GroupedRow( title = stringResource(R.string.settings_week_start), summary = weekStartLabel(state.weekStart), @@ -492,6 +501,34 @@ private fun AppearanceScreen( }, onClick = { viewModel.setShowHourLines(!state.showHourLines) }, ) + + Spacer(Modifier.height(16.dp)) + + // Agenda + GroupedRow( + title = stringResource(R.string.settings_agenda_range), + summary = agendaRangeLabel(state.agendaScreenRange), + position = Position.Top, + onClick = { showAgendaScreenRange = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_agenda_widget_range), + summary = agendaRangeLabel(state.agendaWidgetRange), + position = Position.Middle, + onClick = { showAgendaWidgetRange = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_agenda_range_bar), + summary = stringResource(R.string.settings_agenda_range_bar_hint), + position = Position.Bottom, + trailing = { + Switch( + checked = state.agendaShowRangeBar, + onCheckedChange = viewModel::setAgendaShowRangeBar, + ) + }, + onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) }, + ) } if (showTheme) { @@ -514,6 +551,24 @@ private fun AppearanceScreen( onDismiss = { showWeekStart = false }, ) } + if (showAgendaScreenRange) { + AgendaRangePicker( + title = stringResource(R.string.settings_agenda_range), + description = stringResource(R.string.settings_agenda_range_hint), + selected = state.agendaScreenRange, + onSelect = viewModel::setAgendaScreenRange, + onDismiss = { showAgendaScreenRange = false }, + ) + } + if (showAgendaWidgetRange) { + AgendaRangePicker( + title = stringResource(R.string.settings_agenda_widget_range), + description = stringResource(R.string.settings_agenda_widget_range_hint), + selected = state.agendaWidgetRange, + onSelect = viewModel::setAgendaWidgetRange, + onDismiss = { showAgendaWidgetRange = false }, + ) + } if (showTimeFormat) { OptionPicker( title = stringResource(R.string.settings_time_format), 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 12fde50..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 @@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref 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 /** @@ -23,6 +24,12 @@ data class SettingsUiState( val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, /** Whether the week/day timeline draws an hour separator line (v2.11). */ val showHourLines: Boolean = false, + /** How 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, + /** 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 e452eac..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 @@ -1,9 +1,14 @@ 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 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 @@ -12,7 +17,12 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref 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 import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -21,12 +31,15 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject @HiltViewModel 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 @@ -74,13 +87,24 @@ class SettingsViewModel @Inject constructor( ) { overrides, allDayOverrides, calendars -> ReminderOverrides(overrides, allDayOverrides, calendars) }, - combine(prefs.defaultView, prefs.timeFormat) { view, timeFormat -> view to timeFormat }, - prefs.showHourLines, - ) { base, defaults, overrides, viewAndTimeFormat, showHourLines -> + combine( + prefs.defaultView, + prefs.agendaScreenRange, + prefs.agendaWidgetRange, + prefs.timeFormat, + prefs.showHourLines, + ) { view, screenRange, widgetRange, timeFormat, showHourLines -> + ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines) + }, + prefs.agendaShowRangeBar, + ) { base, defaults, overrides, views, showRangeBar -> base.copy( - defaultView = viewAndTimeFormat.first, - timeFormat = viewAndTimeFormat.second, - showHourLines = showHourLines, + defaultView = views.defaultView, + agendaScreenRange = views.agendaScreenRange, + agendaWidgetRange = views.agendaWidgetRange, + timeFormat = views.timeFormat, + showHourLines = views.showHourLines, + agendaShowRangeBar = showRangeBar, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -96,6 +120,12 @@ class SettingsViewModel @Inject constructor( initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable), ) + // 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, val defaultReminder: Int?, @@ -110,6 +140,14 @@ class SettingsViewModel @Inject constructor( val calendars: List, ) + private data class ViewSettings( + val defaultView: CalendarView, + val agendaScreenRange: AgendaRange, + val agendaWidgetRange: AgendaRange, + val timeFormat: TimeFormatPref, + val showHourLines: Boolean, + ) + fun setThemeMode(mode: ThemeMode) { viewModelScope.launch { prefs.setThemeMode(mode) } } @@ -119,7 +157,40 @@ 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) { + viewModelScope.launch { prefs.setAgendaScreenRange(range) } + } + + fun setAgendaWidgetRange(range: AgendaRange) { + viewModelScope.launch { + prefs.setAgendaWidgetRange(range) + 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) + } + } + } + + fun setAgendaShowRangeBar(enabled: Boolean) { + viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) } } fun setTimeFormat(pref: TimeFormatPref) { 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 e8870fe..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,6 +8,7 @@ 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.groupAgendaDays import kotlinx.coroutines.flow.first @@ -24,9 +25,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 @@ -49,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 } @@ -98,13 +106,22 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val zone = systemZone() val anchor = today(zone) val ep = widgetEntryPoint() - val instances = ep.calendarRepository().instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first() - val is24Hour = ep.settingsPrefs().timeFormat.first() + val prefs = ep.settingsPrefs() + val savedRange = prefs.agendaWidgetRange.first() + val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) + // Load the widest selectable window once; the displayed range is sliced in + // the composition from Glance state, so changing the range is a plain + // 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)) return AgendaWidgetData.Ready( 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) { } } } + } } } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5f700a3..348ba61 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 @@ -282,6 +281,25 @@ 24-Stunden (14:00) Stundenlinien Trennlinie zu jeder vollen Stunde in Wochen- und Tagesansicht anzeigen + Agenda-Zeitraum + 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-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 + Nächste 7 Tage + Nächste 30 Tage + Benutzerdefiniert… + Tage + Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt. + Alle anstehenden Termine für + + %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 67ff790..60c1872 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 @@ -275,6 +274,25 @@ 24-hour (14:00) Hour lines Show a separator line at each hour in week and day view + Agenda range + How far ahead the Agenda screen lists events. + Agenda widget range + How far ahead the agenda home-screen widget lists events. + 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 + Next 7 days + Next 30 days + Custom… + Days + Just for now — resets to your saved range when you reopen Calendula. + Showing all upcoming events for + + %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 7f26267..012b75d 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 @@ -89,6 +90,18 @@ class SettingsPrefsTest { assertThat(prefs.showHourLines.first()).isTrue() } + @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..be030b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt @@ -0,0 +1,83 @@ +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 `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(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, + AgendaRange.ThisWeek, + AgendaRange.ThisMonth, + ).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)) + } +}