feat(agenda): split range picker into two lists with a concrete header
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m27s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 15:59:56 +02:00
parent 3cf1439850
commit 366059b012
7 changed files with 97 additions and 28 deletions

View File

@@ -3,6 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import java.time.YearMonth 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. * 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 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:" private const val CUSTOM_PREFIX = "CUSTOM:"

View File

@@ -176,12 +176,16 @@ fun AgendaScreen(
} }
if (showRangePicker) { if (showRangePicker) {
val locale = currentLocale()
AgendaRangePicker( AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range), title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.agenda_range_override_hint), description = stringResource(R.string.agenda_range_override_hint),
selected = successState?.range ?: AgendaRange.Month, selected = successState?.range ?: AgendaRange.Month,
onSelect = viewModel::setRangeOverride, onSelect = viewModel::setRangeOverride,
onDismiss = { showRangePicker = false }, onDismiss = { showRangePicker = false },
currentWindow = successState?.let {
agendaRangeWindowSummary(it.range, it.anchor, it.rangeEnd, locale)
},
) )
} }
} }

View File

@@ -57,5 +57,7 @@ sealed interface AgendaUiState {
val range: AgendaRange, val range: AgendaRange,
/** True when [range] is a temporary in-view override of the saved default. */ /** True when [range] is a temporary in-view override of the saved default. */
val rangeIsOverride: Boolean, val rangeIsOverride: Boolean,
/** Last day the current [range] covers (inclusive), for the range header. */
val rangeEnd: LocalDate,
) : AgendaUiState ) : AgendaUiState
} }

View File

@@ -123,12 +123,17 @@ class AgendaViewModel @Inject constructor(
} }
val anchor = params.anchor val anchor = params.anchor
val days = groupAgendaDays(anchor, instances, zone) val days = groupAgendaDays(anchor, instances, zone)
val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY,
)
return AgendaUiState.Success( return AgendaUiState.Success(
anchor = anchor, anchor = anchor,
today = todayDate, today = todayDate,
days = days, days = days,
range = params.range, range = params.range,
rangeIsOverride = params.rangeIsOverride, rangeIsOverride = params.rangeIsOverride,
rangeEnd = rangeEnd,
) )
} }
} }

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape 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 * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
* as grouped rows, plus a "Custom" row that expands an inline day-count editor * options (today / this week / this month) and the rolling windows (next 7 / 30
* (1365). Mirrors [ReminderDefaultPicker]'s custom-expand pattern. * days), with a "Custom" row that expands an inline day-count editor (1365).
* When [currentWindow] is given, a header shows the concrete span in effect.
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
*/ */
@Composable @Composable
fun AgendaRangePicker( fun AgendaRangePicker(
@@ -294,40 +297,50 @@ fun AgendaRangePicker(
selected: AgendaRange, selected: AgendaRange,
onSelect: (AgendaRange) -> Unit, onSelect: (AgendaRange) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
currentWindow: String? = null,
) { ) {
val presets = listOf( val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth)
AgendaRange.Day, val rolling = listOf(AgendaRange.Week, AgendaRange.Month)
AgendaRange.ThisWeek, val rollingRowCount = rolling.size + 1 // + the custom row
AgendaRange.ThisMonth,
AgendaRange.Week,
AgendaRange.Month,
)
val customSelected = selected is AgendaRange.Custom val customSelected = selected is AgendaRange.Custom
val rowCount = presets.size + 1 // + the custom row
var customExpanded by rememberSaveable { mutableStateOf(false) } var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { var amountText by rememberSaveable {
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "") 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) { FullScreenPicker(title = title, onDismiss = onDismiss) {
if (currentWindow != null) AgendaRangeHeader(currentWindow)
PickerDescription(description) PickerDescription(description)
presets.forEachIndexed { index, option ->
val isSelected = option == selected // Calendar-aligned windows.
GroupedRow( calendarAligned.forEachIndexed { index, option ->
title = agendaRangeLabel(option), rangeRow(option, positionOf(index, calendarAligned.size))
position = positionOf(index, rowCount), }
selected = isSelected,
trailing = if (isSelected) { Spacer(Modifier.height(12.dp))
{ SelectedCheck() }
} else { // Rolling windows + custom.
null rolling.forEachIndexed { index, option ->
}, rangeRow(option, positionOf(index, rollingRowCount))
onClick = {
onSelect(option)
onDismiss()
},
)
} }
GroupedRow( GroupedRow(
title = if (customSelected) { title = if (customSelected) {
@@ -335,7 +348,7 @@ fun AgendaRangePicker(
} else { } else {
stringResource(R.string.agenda_range_custom) 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, selected = customSelected,
trailing = if (customSelected) { trailing = if (customSelected) {
{ SelectedCheck() } { SelectedCheck() }
@@ -361,6 +374,23 @@ fun AgendaRangePicker(
} }
} }
/** The "Showing all events for <span>" 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 (1365) and Set. */ /** The expanded "Custom" day-count editor: an amount field (1365) and Set. */
@Composable @Composable
private fun CustomDaysEditor( private fun CustomDaysEditor(

View File

@@ -294,6 +294,7 @@
<string name="agenda_range_custom">Benutzerdefiniert…</string> <string name="agenda_range_custom">Benutzerdefiniert…</string>
<string name="agenda_range_custom_hint">Tage</string> <string name="agenda_range_custom_hint">Tage</string>
<string name="agenda_range_override_hint">Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt.</string> <string name="agenda_range_override_hint">Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt.</string>
<string name="agenda_range_showing_label">Alle Termine für</string>
<plurals name="agenda_range_days"> <plurals name="agenda_range_days">
<item quantity="one">%d Tag</item> <item quantity="one">%d Tag</item>
<item quantity="other">%d Tage</item> <item quantity="other">%d Tage</item>

View File

@@ -287,6 +287,7 @@
<string name="agenda_range_custom">Custom…</string> <string name="agenda_range_custom">Custom…</string>
<string name="agenda_range_custom_hint">Days</string> <string name="agenda_range_custom_hint">Days</string>
<string name="agenda_range_override_hint">Just for now — resets to your saved range when you reopen Calendula.</string> <string name="agenda_range_override_hint">Just for now — resets to your saved range when you reopen Calendula.</string>
<string name="agenda_range_showing_label">Showing all events for</string>
<plurals name="agenda_range_days"> <plurals name="agenda_range_days">
<item quantity="one">%d day</item> <item quantity="one">%d day</item>
<item quantity="other">%d days</item> <item quantity="other">%d days</item>