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