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.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:"

View File

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

View File

@@ -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
}

View File

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

View File

@@ -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
* (1365). 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 (1365).
* 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 <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. */
@Composable
private fun CustomDaysEditor(

View File

@@ -294,6 +294,7 @@
<string name="agenda_range_custom">Benutzerdefiniert…</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_showing_label">Alle Termine für</string>
<plurals name="agenda_range_days">
<item quantity="one">%d Tag</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_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_showing_label">Showing all events for</string>
<plurals name="agenda_range_days">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>