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.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:"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
* (1–365). Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
* 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
|
@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 (1–365) and Set. */
|
/** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun CustomDaysEditor(
|
private fun CustomDaysEditor(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user