Show what a setting does, not just what it's called
The settings hub sorted the options into places you'd look for them; this gives the pickers themselves something to say. Three rungs, cheapest first: - Per-option summaries where the label names something it doesn't spell out: each agenda range resolves to real dates (so "This week" and "Next 7 days" stop being the same words), all-day reminders name the hour they actually fire at, time formats render a sample, "follow the system" says which way it currently falls, visibility says what other people see, widget sizes give their text scale, recurrence presets list their next three dates. - Live previews where the effect is visual and no words carry it: past events (show/dim/hide, drawn with the agenda's own rows and filter), week start (the real month grid in the user's own style), and a font specimen set in the role each picker governs. These apply on tap and stay open — closing would hide the thing the screen exists to show. - Everything else left as plain rows. Recurrence gets a proper expansion (domain/RecurrenceOccurrences.kt) rather than a guess: monthly on the 31st skips short months, 29 February yearly only lands in leap years, weekly blocks count from Monday (the WKST that applies, since toRRule writes none), and COUNT counts real occurrences. Years appear in the list as soon as it leaves its starting year. JVM-tested. Alongside, in the hub itself: source, licence and privacy move out of the About card into rows at the foot of the page; support becomes the closing row of the card's own group instead of a button inside it; category chips cycle their accents so no group is a single colour; and section headers now share the cards' left margin (they sat 8dp to the right of everything else). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/**
|
||||
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
||||
* (the event's own start date, i.e. DTSTART), for showing a rule as dates
|
||||
* instead of as words.
|
||||
*
|
||||
* A recurrence rule is the one place in the editor where a correct-sounding
|
||||
* phrase can still mean something the user didn't intend — "every 2 weeks on
|
||||
* Mon, Fri", "monthly" on the 31st — and the only honest answer is the dates
|
||||
* themselves. This is a *preview*, deliberately kept to the shapes the picker
|
||||
* can build; the provider remains the authority on what actually gets stored
|
||||
* and expanded.
|
||||
*
|
||||
* The rules it mirrors, all from RFC 5545 (the spec the platform's recurrence
|
||||
* engine implements):
|
||||
* - a monthly or yearly rule **skips** a period the start day doesn't exist in,
|
||||
* rather than sliding to the month's last day: the 31st recurring monthly
|
||||
* lands only in 31-day months, 29 February yearly only in leap years.
|
||||
* - a weekly rule with weekday picks repeats in blocks of `interval` weeks,
|
||||
* with the week beginning on **Monday** — the RFC's default WKST, and the one
|
||||
* that applies here because [toRRule] never writes a WKST part. The
|
||||
* display-only "first day of week" preference deliberately doesn't enter into
|
||||
* it, or the preview would disagree with the stored rule.
|
||||
* - [RecurrenceEnd.Count] counts real occurrences (skipped periods don't
|
||||
* consume one), [RecurrenceEnd.Until] is inclusive of its date.
|
||||
*
|
||||
* Returns fewer than [limit] dates when the series ends first, and an empty
|
||||
* list when the rule yields nothing at all (an UNTIL before the start).
|
||||
*/
|
||||
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
|
||||
if (limit <= 0) return emptyList()
|
||||
val until = (end as? RecurrenceEnd.Until)?.date
|
||||
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
||||
val wanted = minOf(limit, maxCount)
|
||||
if (wanted <= 0) return emptyList()
|
||||
|
||||
val result = mutableListOf<LocalDate>()
|
||||
var period = 0
|
||||
// Periods that produce nothing (a skipped 31st, a weekday block whose picks
|
||||
// all fall before the start) must not stall the walk, so the cap is on
|
||||
// periods examined rather than on dates found.
|
||||
while (result.size < wanted && period < MAX_PERIODS) {
|
||||
for (date in occurrencesInPeriod(period, start)) {
|
||||
if (date < start) continue
|
||||
if (until != null && date > until) return result
|
||||
result += date
|
||||
if (result.size == wanted) return result
|
||||
}
|
||||
period++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
|
||||
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
|
||||
when (freq) {
|
||||
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
|
||||
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
||||
RecurrenceFreq.Monthly -> {
|
||||
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
||||
// plus() clamps the day into the shorter month; the rule instead
|
||||
// skips such a period, so a clamped date means "not this month".
|
||||
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
|
||||
}
|
||||
RecurrenceFreq.Yearly ->
|
||||
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
|
||||
}
|
||||
|
||||
/**
|
||||
* One weekly block: every picked weekday inside the week that begins
|
||||
* `period * interval` weeks after the start's own week, in weekday order. With
|
||||
* no picks the rule simply repeats the start's weekday.
|
||||
*/
|
||||
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
|
||||
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
|
||||
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
|
||||
DAYS_PER_WEEK
|
||||
val weekStart = start
|
||||
.minus(daysIntoWeek, DateTimeUnit.DAY)
|
||||
.plus(period * interval, DateTimeUnit.WEEK)
|
||||
return byDays.sortedBy { it.isoDayNumber }.map { day ->
|
||||
weekStart.plus(
|
||||
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run of [occurrences] starting at [start] leaves its starting year —
|
||||
* i.e. whether showing them without a year would be ambiguous.
|
||||
*
|
||||
* Day and month alone are enough for a rule that stays inside one year, and
|
||||
* dropping the year keeps the line short. They are actively misleading
|
||||
* otherwise: a yearly rule reads as the same date three times over, and a
|
||||
* monthly one rolling past December gives no hint that it has.
|
||||
*/
|
||||
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
|
||||
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
|
||||
|
||||
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
|
||||
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
||||
runCatching { LocalDate(year, month, day) }.getOrNull()
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* How many repetitions to examine before giving up. Generous enough for the
|
||||
* sparsest rule the picker can build (29 February yearly with a 999-year
|
||||
* interval is nonsense; 31st monthly needs at most a handful), and bounded so a
|
||||
* rule whose occurrences all fall outside its own UNTIL can't spin.
|
||||
*/
|
||||
private const val MAX_PERIODS = 2_000
|
||||
@@ -93,6 +93,7 @@ fun AgendaScreen(
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -198,6 +199,7 @@ fun AgendaScreen(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.agenda_range_override_hint),
|
||||
selected = successState?.range ?: AgendaRange.Month,
|
||||
weekStart = weekStart,
|
||||
onSelect = viewModel::setRangeOverride,
|
||||
onDismiss = { showRangePicker = false },
|
||||
)
|
||||
|
||||
@@ -48,8 +48,12 @@ class AgendaViewModel @Inject constructor(
|
||||
settingsPrefs.agendaShowRangeBar,
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
// First day of the week, for the calendar-aligned "this week" range.
|
||||
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
/**
|
||||
* First day of the week, for the calendar-aligned "this week" range. Public
|
||||
* because the range picker resolves each option to real dates, which needs
|
||||
* the same week start the window is built from.
|
||||
*/
|
||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
@@ -90,7 +94,7 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
|
||||
@@ -97,6 +97,7 @@ import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -734,7 +735,13 @@ internal fun SectionHeader(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
// The cards' own edge, so header and group share a left margin.
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -744,7 +751,7 @@ internal fun HintText(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,16 +23,26 @@ import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
@@ -48,6 +58,11 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||
* back.
|
||||
*
|
||||
* [leadTimeSummary] adds a second line to each lead-time row. It exists for the
|
||||
* all-day pickers, where the lead time alone doesn't say when anything happens:
|
||||
* the hour comes from the separate "show all-day reminders at" setting, so
|
||||
* "1 day before" is only half the answer until the row spells out the time.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderDefaultPicker(
|
||||
@@ -57,6 +72,7 @@ fun ReminderDefaultPicker(
|
||||
allowInherit: Boolean,
|
||||
onSelect: (ReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
leadTimeSummary: (@Composable (Int) -> String?)? = null,
|
||||
) {
|
||||
// Optimistic local state: once the user edits, the chosen override is
|
||||
// authoritative while the picker is open, so quick successive toggles compose
|
||||
@@ -132,6 +148,7 @@ fun ReminderDefaultPicker(
|
||||
val checked = minute in selectedMinutes
|
||||
GroupedRow(
|
||||
title = reminderLeadTimeLabel(minute),
|
||||
summary = leadTimeSummary?.invoke(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = checked,
|
||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||
@@ -196,14 +213,15 @@ private fun CustomReminderEditor(
|
||||
)
|
||||
}
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows,
|
||||
* on the same left margin as the rows themselves. */
|
||||
@Composable
|
||||
internal fun PickerDescription(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -213,12 +231,19 @@ internal fun PickerDescription(text: String) {
|
||||
* 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.
|
||||
*
|
||||
* Every option carries the dates it resolves to today as its summary, computed
|
||||
* through the same [dayCount] the agenda windows by (hence [weekStart], which
|
||||
* "This week" depends on). Without it the two groups are near-indistinguishable
|
||||
* — "This week" and "Next 7 days" name different windows in the same words, and
|
||||
* only the concrete span tells them apart.
|
||||
*/
|
||||
@Composable
|
||||
fun AgendaRangePicker(
|
||||
title: String,
|
||||
description: String,
|
||||
selected: AgendaRange,
|
||||
weekStart: DayOfWeek,
|
||||
onSelect: (AgendaRange) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -232,10 +257,21 @@ fun AgendaRangePicker(
|
||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||
}
|
||||
|
||||
val locale = currentLocale()
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
// The window each option would open right now — the agenda's own end-day
|
||||
// arithmetic (anchor + dayCount - 1), so the dates match what it will show.
|
||||
val windowSummary: (AgendaRange) -> String = { range ->
|
||||
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
|
||||
agendaRangeWindowSummary(range, today, end, locale)
|
||||
}
|
||||
|
||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = agendaRangeLabel(option),
|
||||
summary = windowSummary(option),
|
||||
position = position,
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
@@ -270,6 +306,9 @@ fun AgendaRangePicker(
|
||||
} else {
|
||||
stringResource(R.string.agenda_range_custom)
|
||||
},
|
||||
// Only a chosen custom window has dates to show; an unset one has no
|
||||
// day count yet, so the row stays single-line until it does.
|
||||
summary = if (customSelected) windowSummary(selected) else null,
|
||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
|
||||
@@ -8,6 +8,11 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
|
||||
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
@@ -98,6 +103,43 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
|
||||
* the recurrence picker.
|
||||
*
|
||||
* The phrase [recurrenceText] renders says what the rule *is*; this says what
|
||||
* it *does*, which is the part a monthly rule on the 31st or an every-other-week
|
||||
* rule with weekday picks gets wrong in people's heads. It expands the rule
|
||||
* with [upcomingOccurrences], so it inherits that function's RFC reading — and
|
||||
* its limits: only the shapes the picker itself can build.
|
||||
*
|
||||
* A rule that yields nothing at all (an end date before the start) says so
|
||||
* rather than showing an empty list, since that is a mistake worth catching
|
||||
* before saving.
|
||||
*/
|
||||
@Composable
|
||||
fun nextOccurrencesText(
|
||||
rule: SimpleRecurrence,
|
||||
// Spelled out: in this file, the bare LocalDate is java.time's.
|
||||
start: kotlinx.datetime.LocalDate,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
|
||||
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
|
||||
// Years only once they carry information — a yearly rule is otherwise the
|
||||
// same date repeated, and a monthly one crossing New Year hides that it did.
|
||||
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
|
||||
val formatter = localizedDateFormatter(locale, pattern)
|
||||
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }
|
||||
return stringResource(
|
||||
R.string.event_edit_recurrence_next,
|
||||
ListFormatter.getInstance(locale).format(formatted),
|
||||
)
|
||||
}
|
||||
|
||||
/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */
|
||||
private const val NEXT_OCCURRENCE_COUNT = 3
|
||||
|
||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
val dow = when (token.takeLast(2).uppercase()) {
|
||||
|
||||
@@ -39,6 +39,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -240,7 +241,12 @@ private fun SectionHeader(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -1172,7 +1173,7 @@ private fun EventEditContent(
|
||||
if (showRecurrencePicker) {
|
||||
RecurrencePickerDialog(
|
||||
current = form.rrule,
|
||||
startDay = form.start.date.dayOfWeek,
|
||||
startDate = form.start.date,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
onSelect = { rrule ->
|
||||
viewModel.setRecurrence(rrule)
|
||||
@@ -1340,15 +1341,23 @@ private enum class RecurrenceEndMode { Never, Until, Count }
|
||||
* and an end condition — only that step needs an OK button. A rule the
|
||||
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
||||
* user picks something here.
|
||||
*
|
||||
* Both steps show the rule as *dates* as well as words: every preset row
|
||||
* carries the next few occurrences it would produce from [startDate], and the
|
||||
* custom step repeats that under its live read-out. It is where a rule most
|
||||
* easily means something other than it sounds like — "monthly" on the 31st
|
||||
* skips February, an every-other-week rule lands a fortnight from the start's
|
||||
* own week — and only the dates say so.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecurrencePickerDialog(
|
||||
current: String?,
|
||||
startDay: DayOfWeek,
|
||||
startDate: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
onSelect: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val startDay = startDate.dayOfWeek
|
||||
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
||||
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
||||
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
||||
@@ -1400,16 +1409,20 @@ private fun RecurrencePickerDialog(
|
||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
||||
}
|
||||
val customResult: String? = if (interval != null && customEnd != null) {
|
||||
// Kept as the rule object, not just its RRULE text: the read-out renders the
|
||||
// string, the date list expands the rule, and both must describe the one
|
||||
// thing OK would save.
|
||||
val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) {
|
||||
SimpleRecurrence(
|
||||
freq = freq,
|
||||
interval = interval,
|
||||
end = customEnd,
|
||||
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
||||
).toRRule()
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val customResult: String? = customRule?.toRRule()
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_recurrence),
|
||||
@@ -1440,6 +1453,7 @@ private fun RecurrencePickerDialog(
|
||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||
GroupedRow(
|
||||
title = stringResource(recurrencePresetLabel(entry)),
|
||||
summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale),
|
||||
position = positionOf(index + 1, rowCount),
|
||||
selected = isPlainPreset && parsed?.freq == entry,
|
||||
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
||||
@@ -1491,6 +1505,18 @@ private fun RecurrencePickerDialog(
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
// The same rule as dates. Rendered even while the form is
|
||||
// incomplete (as an empty line) so the controls below don't
|
||||
// shift as it comes and goes.
|
||||
Text(
|
||||
text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
// How often: an interval amount plus a frequency segmented row —
|
||||
// all four units on show, no unit hidden behind a dropdown.
|
||||
GroupedSurface(
|
||||
@@ -2007,6 +2033,11 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||
/**
|
||||
* Visibility selector: one card per level, each with its own icon; the
|
||||
* current level is highlighted. Tap picks and closes.
|
||||
*
|
||||
* Every level carries a line saying who this affects, because the four words
|
||||
* alone don't: visibility is about what *other people on a shared calendar*
|
||||
* see, which is exactly the part the label leaves out — and "Confidential" has
|
||||
* no meaning at all until you know the server decides what it does with it.
|
||||
*/
|
||||
@Composable
|
||||
private fun VisibilityPickerDialog(
|
||||
@@ -2019,6 +2050,7 @@ private fun VisibilityPickerDialog(
|
||||
options = AccessLevel.entries.toList(),
|
||||
selected = selected,
|
||||
label = { stringResource(accessLevelLabel(it)) },
|
||||
summary = { stringResource(accessLevelSummary(it)) },
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
||||
@@ -2111,6 +2143,14 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
|
||||
AccessLevel.Confidential -> R.string.event_access_confidential
|
||||
}
|
||||
|
||||
/** What each level means for people the calendar is shared with. */
|
||||
private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
|
||||
AccessLevel.Default -> R.string.event_access_default_summary
|
||||
AccessLevel.Public -> R.string.event_access_public_summary
|
||||
AccessLevel.Private -> R.string.event_access_private_summary
|
||||
AccessLevel.Confidential -> R.string.event_access_confidential_summary
|
||||
}
|
||||
|
||||
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
||||
@Composable
|
||||
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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
|
||||
@@ -50,12 +51,14 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -208,12 +211,28 @@ internal fun AppearanceScreen(
|
||||
}
|
||||
}
|
||||
if (showTheme) {
|
||||
// No preview here on purpose: picking a theme repaints the app itself,
|
||||
// which is a better demonstration than any thumbnail. What the list
|
||||
// *can't* show is which way "Follow the system" currently falls, so that
|
||||
// one option says so.
|
||||
val systemDark = isSystemInDarkTheme()
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
header = { PickerDescription(stringResource(R.string.settings_theme_hint)) },
|
||||
predictiveBack = true,
|
||||
options = ThemeMode.entries,
|
||||
selected = state.themeMode,
|
||||
label = { themeLabel(it) },
|
||||
summary = { mode ->
|
||||
if (mode == ThemeMode.SYSTEM) {
|
||||
stringResource(
|
||||
R.string.settings_theme_system_summary,
|
||||
themeLabel(if (systemDark) ThemeMode.DARK else ThemeMode.LIGHT),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
onSelect = viewModel::setThemeMode,
|
||||
onDismiss = { showTheme = false },
|
||||
)
|
||||
@@ -292,6 +311,15 @@ private val FONT_PICKER_MIME_TYPES = arrayOf(
|
||||
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
|
||||
* a file is validated and imported by the caller, switching to the custom font
|
||||
* on success.
|
||||
*
|
||||
* Above the rows sits a specimen of the *current* choice, set in the type role
|
||||
* this picker governs — headline for [FontRole.BRAND], body for
|
||||
* [FontRole.PLAIN]. The per-row "Ag" says what a face looks like; only a full
|
||||
* line at the real size says whether it works for the role, and it is the one
|
||||
* thing that makes the two font settings distinguishable from each other.
|
||||
*
|
||||
* Being a preview picker, it stays open on selection (see `FullScreenPicker`):
|
||||
* the specimen re-renders instead, which is the whole point of the screen.
|
||||
*/
|
||||
@Composable
|
||||
private fun FontPicker(
|
||||
@@ -307,10 +335,9 @@ private fun FontPicker(
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
onImport(uri)
|
||||
onDismiss()
|
||||
}
|
||||
// Stay open on a successful import too: the specimen switches to the
|
||||
// imported face, which is the only look the user gets before committing.
|
||||
if (uri != null) onImport(uri)
|
||||
}
|
||||
|
||||
// System default + the bundled fonts + the "Choose file…" row.
|
||||
@@ -322,17 +349,22 @@ private fun FontPicker(
|
||||
val customPreview = remember(role, isCustom, stamp) {
|
||||
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
|
||||
}
|
||||
// The face the specimen is set in: the same resolution the theme performs,
|
||||
// so what is shown here is what the app will use.
|
||||
val selectedFamily = remember(role, selected, stamp) {
|
||||
resolveFontFamily(selected, role, context)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||
FontSpecimen(role = role, family = selectedFamily)
|
||||
FontOptionRow(
|
||||
label = stringResource(R.string.settings_font_system),
|
||||
preview = FontFamily.Default,
|
||||
selected = selected == FONT_SYSTEM_TOKEN,
|
||||
position = positionOf(0, rowCount),
|
||||
onClick = {
|
||||
onSelect(FONT_SYSTEM_TOKEN)
|
||||
onDismiss()
|
||||
},
|
||||
// Applies straight away and stays open — the specimen above is the
|
||||
// answer to "what does this one look like".
|
||||
onClick = { onSelect(FONT_SYSTEM_TOKEN) },
|
||||
)
|
||||
BundledFont.entries.forEachIndexed { index, font ->
|
||||
FontOptionRow(
|
||||
@@ -340,10 +372,7 @@ private fun FontPicker(
|
||||
preview = font.family,
|
||||
selected = selected == font.token,
|
||||
position = positionOf(index + 1, rowCount),
|
||||
onClick = {
|
||||
onSelect(font.token)
|
||||
onDismiss()
|
||||
},
|
||||
onClick = { onSelect(font.token) },
|
||||
)
|
||||
}
|
||||
FontOptionRow(
|
||||
@@ -362,6 +391,36 @@ private fun FontPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A specimen of [family] set in the type role [role] governs: a headline for
|
||||
* the brand role, a paragraph for the plain one — the same styles the app draws
|
||||
* with, only the family swapped, so nothing here can flatter a face the app
|
||||
* won't reproduce. A null [family] is the system typeface, i.e. the Material
|
||||
* default the styles already carry.
|
||||
*/
|
||||
@Composable
|
||||
private fun FontSpecimen(role: FontRole, family: FontFamily?) {
|
||||
val isBrand = role == FontRole.BRAND
|
||||
val style = if (isBrand) {
|
||||
MaterialTheme.typography.headlineMedium
|
||||
} else {
|
||||
MaterialTheme.typography.bodyLarge
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isBrand) R.string.settings_font_specimen_heading else R.string.settings_font_specimen_body,
|
||||
),
|
||||
style = style.copy(fontFamily = family ?: style.fontFamily),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
// Two lines up front so switching between a wide and a narrow face
|
||||
// doesn't shuffle the option list up and down under it.
|
||||
minLines = 2,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
|
||||
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.text.format.DateFormat
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -52,7 +51,6 @@ import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import kotlinx.datetime.LocalTime
|
||||
import java.util.Calendar
|
||||
|
||||
/**
|
||||
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
||||
@@ -120,7 +118,7 @@ internal fun NotificationsScreen(
|
||||
title = stringResource(R.string.settings_allday_reminder_time),
|
||||
summary = stringResource(
|
||||
R.string.settings_allday_reminder_time_hint,
|
||||
formatTimeOfDay(context, state.allDayReminderTimeMinutes),
|
||||
settingsTimeOfDay(state.allDayReminderTimeMinutes),
|
||||
),
|
||||
position = Position.Bottom,
|
||||
onClick = { showAllDayReminderTime = true },
|
||||
@@ -281,6 +279,7 @@ internal fun NotificationsScreen(
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
||||
onDismiss = { showAllDayReminder = false },
|
||||
leadTimeSummary = allDayFiringTimeSummary(state.allDayReminderTimeMinutes),
|
||||
)
|
||||
}
|
||||
if (showAllDayReminderTime) {
|
||||
@@ -321,6 +320,11 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
},
|
||||
onDismiss = { overrideDialog = null },
|
||||
leadTimeSummary = if (target.isAllDay) {
|
||||
allDayFiringTimeSummary(state.allDayReminderTimeMinutes)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -360,15 +364,6 @@ private fun snoozeDurationLabel(minutes: Int): String =
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
|
||||
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||
val time = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, minutesOfDay / 60)
|
||||
set(Calendar.MINUTE, minutesOfDay % 60)
|
||||
}.time
|
||||
return DateFormat.getTimeFormat(context).format(time)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||
* `ON_RESUME` so the row reflects a change the user just made in system
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* The "past events" chooser for the Agenda (#69 follow-up).
|
||||
*
|
||||
* "Show / dim / hide" is the case where the word cannot carry the choice —
|
||||
* *how far* does dimmed fade, and what is left behind when finished events are
|
||||
* hidden? So a stand-in agenda day sits above the options and re-renders as you
|
||||
* pick one, using the very rows the Agenda draws: dimming is
|
||||
* [AgendaEventRow]'s own `dimmed` flag, and hiding drops the row exactly as
|
||||
* [de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen] filters it — so the
|
||||
* preview cannot claim an effect the screen doesn't have.
|
||||
*
|
||||
* Like the Month style and App name pickers, selecting applies immediately and
|
||||
* leaves the picker open: closing would hide the thing the screen is for. Back
|
||||
* exits.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PastEventsPicker(
|
||||
selected: PastEventDisplay,
|
||||
onSelect: (PastEventDisplay) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val options = PastEventDisplay.entries
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.height(PREVIEW_HEIGHT),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = "past-events-preview",
|
||||
) { shown ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
PastEventsPreview(mode = shown)
|
||||
}
|
||||
}
|
||||
}
|
||||
PickerDescription(stringResource(R.string.settings_past_events_hint))
|
||||
options.forEachIndexed { index, mode ->
|
||||
val isSelected = mode == selected
|
||||
GroupedRow(
|
||||
title = stringResource(pastEventDisplayLabelRes(mode)),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// Applies straight away; the preview above is the confirmation.
|
||||
onClick = { onSelect(mode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in agenda day in a given [mode]: three events on today, the first two
|
||||
* already finished. The times are fixed rather than derived from the clock, so
|
||||
* the preview reads the same at any hour — what varies is only what [mode] does
|
||||
* to the two finished rows.
|
||||
*/
|
||||
@Composable
|
||||
private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modifier) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val titles = listOf(
|
||||
stringResource(R.string.settings_past_events_sample_morning),
|
||||
stringResource(R.string.settings_past_events_sample_midday),
|
||||
stringResource(R.string.settings_past_events_sample_evening),
|
||||
)
|
||||
val sample = remember(today, zone, titles) { samplePastDay(today, zone, titles) }
|
||||
// Hiding drops the finished rows outright; showing and dimming keep them,
|
||||
// and only dimming fades them — the same three-way split AgendaContent makes.
|
||||
val visible = if (mode == PastEventDisplay.HIDE) sample.filterNot { it.hasPassed } else sample
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clipToBounds()
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// rows' own clickables see them, and keep it out of the reading order —
|
||||
// the options below already say what is selected.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { },
|
||||
) {
|
||||
AgendaDayHeader(date = today, today = today, onOpenDay = {})
|
||||
visible.forEachIndexed { index, row ->
|
||||
AgendaEventRow(
|
||||
event = row.event,
|
||||
day = today,
|
||||
zone = zone,
|
||||
position = positionOf(index, visible.size),
|
||||
dimmed = mode == PastEventDisplay.DIM && row.hasPassed,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One stand-in row plus whether it counts as finished. */
|
||||
private class SampleRow(val event: EventInstance, val hasPassed: Boolean)
|
||||
|
||||
/**
|
||||
* Today's stand-in events, [titles] in order: two finished (morning, midday) and
|
||||
* one still to come.
|
||||
*
|
||||
* Colours are raw ARGB on purpose — that is what the provider hands out for an
|
||||
* event, so a theme token here would misrepresent what the agenda renders.
|
||||
*/
|
||||
private fun samplePastDay(today: LocalDate, zone: TimeZone, titles: List<String>): List<SampleRow> =
|
||||
listOf(
|
||||
sampleRow(1, titles[0], today, zone, 9, 10, 0xFF3F7BD4.toInt(), hasPassed = true),
|
||||
sampleRow(2, titles[1], today, zone, 12, 13, 0xFFCE5B4C.toInt(), hasPassed = true),
|
||||
sampleRow(3, titles[2], today, zone, 18, 19, 0xFF4E9A6A.toInt(), hasPassed = false),
|
||||
)
|
||||
|
||||
private fun sampleRow(
|
||||
id: Long,
|
||||
title: String,
|
||||
day: LocalDate,
|
||||
zone: TimeZone,
|
||||
startHour: Int,
|
||||
endHour: Int,
|
||||
color: Int,
|
||||
hasPassed: Boolean,
|
||||
): SampleRow = SampleRow(
|
||||
event = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = day.atTime(startHour, 0).toInstant(zone),
|
||||
end = day.atTime(endHour, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = color,
|
||||
location = null,
|
||||
),
|
||||
hasPassed = hasPassed,
|
||||
)
|
||||
|
||||
/** Room for the day header plus its three two-line rows, so hiding shortens the
|
||||
* list inside a box that keeps its height instead of the page jumping a row. */
|
||||
private val PREVIEW_HEIGHT = 288.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
@@ -19,7 +19,11 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
* Pieces shared by the settings hub and its sub-screens (`*Settings.kt`). Each
|
||||
@@ -30,8 +34,14 @@ import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||
* Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt
|
||||
* to theme, dark mode and dynamic colour.
|
||||
*
|
||||
* The chips are a scanning aid, so the rule is **no two rows in one group share
|
||||
* an accent** — a group in a single colour carries no more information than no
|
||||
* colour at all. [Neutral] is not a fourth colour to rotate through but a
|
||||
* deliberate step back, for rows that are reference or last-resort rather than
|
||||
* somewhere you routinely go.
|
||||
*/
|
||||
internal enum class ChipAccent { Neutral, Primary, Tertiary }
|
||||
internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary }
|
||||
|
||||
/**
|
||||
* Leading circular icon chip. Colours come from the M3 scheme via a container /
|
||||
@@ -44,6 +54,7 @@ internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
||||
val (background, iconColor) = when (accent) {
|
||||
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
|
||||
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||
ChipAccent.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer
|
||||
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||
}
|
||||
Box(
|
||||
@@ -62,14 +73,24 @@ internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A small primary-coloured group label, matching the Calendars settings screen. */
|
||||
/**
|
||||
* A small primary-coloured group label, matching the Calendars settings screen.
|
||||
*
|
||||
* Starts on [GroupedListInset] — the cards' own edge — so a header, the hint
|
||||
* under it and the group it names all share one left margin.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,7 +101,7 @@ internal fun SettingsHint(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -102,3 +123,34 @@ internal fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||
* with the contact special-dates calendars, whose events are all all-day.
|
||||
*/
|
||||
internal val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
||||
|
||||
/**
|
||||
* A minute-of-day ("09:00") in the app's *own* 12/24-hour convention — the same
|
||||
* [LocalUse24HourFormat] the agenda and event rows read, so a time shown in
|
||||
* settings can't disagree with the times shown everywhere else.
|
||||
*/
|
||||
@Composable
|
||||
internal fun settingsTimeOfDay(minutesOfDay: Int): String =
|
||||
formatMinuteOfDay(minutesOfDay, LocalUse24HourFormat.current, currentLocale())
|
||||
|
||||
/**
|
||||
* Second line for an all-day lead-time row: the clock time it fires at.
|
||||
*
|
||||
* An all-day occurrence has no time of its own, so a reminder's stored offset
|
||||
* only decides *which day* it belongs to — the hour always comes from the
|
||||
* global "show all-day reminders at" setting (see
|
||||
* [de.jeanlucmakiola.calendula.domain.reminders.planReminders]). Naming it on
|
||||
* every row is what makes "At time of event" readable on an all-day default at
|
||||
* all: it means that day at 09:00, not midnight.
|
||||
*
|
||||
* Shared by the notification defaults, the per-calendar overrides and the
|
||||
* contact special-dates calendars — all three read the same one setting.
|
||||
*/
|
||||
@Composable
|
||||
internal fun allDayFiringTimeSummary(allDayReminderTimeMinutes: Int): @Composable (Int) -> String? {
|
||||
val summary = stringResource(
|
||||
R.string.settings_allday_reminder_fires_at,
|
||||
settingsTimeOfDay(allDayReminderTimeMinutes),
|
||||
)
|
||||
return { summary }
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.floret.components.AboutCard
|
||||
import de.jeanlucmakiola.floret.components.AboutLink
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -166,25 +166,31 @@ private fun SettingsHub(
|
||||
onOpenBackup: () -> Unit,
|
||||
) {
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
|
||||
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
||||
// The card and the support row are one grouped block: the call to action
|
||||
// is a row continuing the container rather than a tonal button sitting
|
||||
// inside the card.
|
||||
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
|
||||
SupportRow()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Three labelled groups instead of one long undifferentiated list (#69):
|
||||
// how the app presents itself, what it does with your data, and the app
|
||||
// as an installed thing.
|
||||
// as an installed thing. Each group cycles its chip accents so no two
|
||||
// rows in it look alike (see [ChipAccent]) — a uniformly coloured group
|
||||
// is as unscannable as an uncoloured one.
|
||||
SectionHeader(stringResource(R.string.settings_group_look))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
summary = stringResource(R.string.settings_appearance_subtitle),
|
||||
position = Position.Top,
|
||||
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
|
||||
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Secondary) },
|
||||
onClick = { onOpenSection(SettingsSection.Appearance) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
summary = stringResource(R.string.settings_views_subtitle),
|
||||
position = Position.Middle,
|
||||
leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) },
|
||||
leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.Views) },
|
||||
)
|
||||
GroupedRow(
|
||||
@@ -215,7 +221,7 @@ private fun SettingsHub(
|
||||
title = stringResource(R.string.settings_section_special_dates),
|
||||
summary = stringResource(R.string.settings_special_dates_subtitle),
|
||||
position = Position.Middle,
|
||||
leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Tertiary) },
|
||||
leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Primary) },
|
||||
onClick = { onOpenSection(SettingsSection.SpecialDates) },
|
||||
)
|
||||
// Export/import used to hide inside the calendar manager, where nobody
|
||||
@@ -225,7 +231,7 @@ private fun SettingsHub(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
summary = stringResource(R.string.settings_backup_subtitle),
|
||||
position = Position.Bottom,
|
||||
leading = { CategoryIcon(Icons.Default.Backup, ChipAccent.Tertiary) },
|
||||
leading = { CategoryIcon(Icons.Default.Backup, ChipAccent.Secondary) },
|
||||
onClick = onOpenBackup,
|
||||
)
|
||||
|
||||
@@ -235,12 +241,37 @@ private fun SettingsHub(
|
||||
title = stringResource(R.string.settings_section_widgets),
|
||||
summary = stringResource(R.string.settings_widgets_subtitle),
|
||||
position = Position.Top,
|
||||
leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Neutral) },
|
||||
leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) },
|
||||
onClick = { onOpenSection(SettingsSection.Widgets) },
|
||||
)
|
||||
LanguageRow(position = Position.Middle)
|
||||
ReportProblemRow(position = Position.Bottom)
|
||||
|
||||
// Source, licence and privacy sit at the very bottom rather than in the
|
||||
// About card: they are reference material you look up once, not settings
|
||||
// you come here to change, and the card at the top now leads with what
|
||||
// the app *is* plus the one link worth offering (support).
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_group_about))
|
||||
AboutLinkRow(
|
||||
label = stringResource(R.string.settings_about_source),
|
||||
url = stringResource(R.string.about_source_url),
|
||||
icon = ImageVector.vectorResource(R.drawable.ic_gitea),
|
||||
position = Position.Top,
|
||||
)
|
||||
AboutLinkRow(
|
||||
label = stringResource(R.string.settings_license),
|
||||
url = stringResource(R.string.about_license_url),
|
||||
icon = Icons.Default.Gavel,
|
||||
position = Position.Middle,
|
||||
)
|
||||
AboutLinkRow(
|
||||
label = stringResource(R.string.settings_about_privacy),
|
||||
url = stringResource(R.string.about_privacy_url),
|
||||
icon = Icons.Default.PrivacyTip,
|
||||
position = Position.Bottom,
|
||||
)
|
||||
|
||||
AppVersionText()
|
||||
}
|
||||
}
|
||||
@@ -295,7 +326,7 @@ private fun LanguageRow(position: Position) {
|
||||
title = stringResource(R.string.settings_language),
|
||||
summary = languageLabel(current),
|
||||
position = position,
|
||||
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
||||
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) },
|
||||
onClick = { showDialog = true },
|
||||
)
|
||||
|
||||
@@ -334,35 +365,50 @@ private fun languageLabel(tag: String?): String =
|
||||
@Composable
|
||||
private fun AboutCard() {
|
||||
// The card layout lives in floret-kit (components.AboutCard); Calendula
|
||||
// supplies its own logo, author and the source / licence / privacy / support
|
||||
// links. The privacy policy has to be reachable from inside the app, not just
|
||||
// from the store listing, because Calendula touches calendar and contact data.
|
||||
// supplies its own logo and author. Source, licence and privacy moved to
|
||||
// rows at the foot of the page, and support is now the [SupportRow] joined
|
||||
// to the bottom of this card — so the card itself carries nothing but what
|
||||
// the app is and who made it.
|
||||
AboutCard(
|
||||
logo = { AppLogo() },
|
||||
appName = stringResource(R.string.app_name),
|
||||
author = stringResource(R.string.settings_about_author),
|
||||
primaryLinks = listOf(
|
||||
AboutLink(
|
||||
icon = ImageVector.vectorResource(R.drawable.ic_gitea),
|
||||
label = stringResource(R.string.settings_about_source),
|
||||
url = stringResource(R.string.about_source_url),
|
||||
),
|
||||
AboutLink(
|
||||
icon = Icons.Default.Gavel,
|
||||
label = stringResource(R.string.settings_license),
|
||||
url = stringResource(R.string.about_license_url),
|
||||
),
|
||||
AboutLink(
|
||||
icon = Icons.Default.PrivacyTip,
|
||||
label = stringResource(R.string.settings_about_privacy),
|
||||
url = stringResource(R.string.about_privacy_url),
|
||||
),
|
||||
),
|
||||
highlightLink = AboutLink(
|
||||
icon = Icons.Default.Favorite,
|
||||
label = stringResource(R.string.settings_about_support),
|
||||
url = stringResource(R.string.about_support_url),
|
||||
),
|
||||
primaryLinks = emptyList(),
|
||||
position = Position.Top,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Support development", as the closing row of the About card's group rather
|
||||
* than a tonal button inside it. Same weight as any other row you can tap, with
|
||||
* a primary chip to keep it the one accent in this block.
|
||||
*/
|
||||
@Composable
|
||||
private fun SupportRow() {
|
||||
val context = LocalContext.current
|
||||
val url = stringResource(R.string.about_support_url)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_about_support),
|
||||
position = Position.Bottom,
|
||||
leading = { CategoryIcon(Icons.Default.Favorite, ChipAccent.Primary) },
|
||||
onClick = { openUrl(context, url) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One reference link at the foot of the hub (source, licence, privacy): a plain
|
||||
* grouped row that opens the URL in the browser. The privacy policy in
|
||||
* particular has to stay reachable from inside the app, not only from the store
|
||||
* listing, because Calendula touches calendar and contact data.
|
||||
*/
|
||||
@Composable
|
||||
private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position: Position) {
|
||||
val context = LocalContext.current
|
||||
GroupedRow(
|
||||
title = label,
|
||||
position = position,
|
||||
leading = { CategoryIcon(icon, ChipAccent.Neutral) },
|
||||
onClick = { openUrl(context, url) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ internal fun SpecialDatesScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||
// The all-day reminder hour lives in the general settings state; the reminder
|
||||
// picker below names it, since that is when these reminders actually fire.
|
||||
val settings by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
// READ_CONTACTS is requested only here, on enable — never at startup. On a
|
||||
@@ -219,6 +222,9 @@ internal fun SpecialDatesScreen(
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
||||
onDismiss = { reminderPickerType = null },
|
||||
// Special dates are all-day events, so their reminders fire at the
|
||||
// one global all-day hour like any other all-day reminder.
|
||||
leadTimeSummary = allDayFiringTimeSummary(settings.allDayReminderTimeMinutes),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
@@ -31,6 +34,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
@@ -262,23 +266,32 @@ internal fun ViewsScreen(
|
||||
options = IMPLEMENTED_VIEWS,
|
||||
selected = state.defaultView,
|
||||
label = { stringResource(it.labelRes) },
|
||||
// The same icon each view carries in the drawer and the switcher
|
||||
// pill, so the row is matched to the view by eye, not by reading.
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = it.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onSelect = viewModel::setDefaultView,
|
||||
onDismiss = { showDefaultView = false },
|
||||
)
|
||||
}
|
||||
if (showWeekStart) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
header = { PickerDescription(stringResource(R.string.settings_week_start_hint)) },
|
||||
predictiveBack = true,
|
||||
options = WEEK_START_OPTIONS,
|
||||
WeekStartPicker(
|
||||
selected = state.weekStart,
|
||||
label = { weekStartLabel(it) },
|
||||
// The preview is a real grid, so it uses the user's own month style.
|
||||
monthStyle = state.monthViewStyle,
|
||||
options = WEEK_START_OPTIONS,
|
||||
onSelect = viewModel::setWeekStart,
|
||||
onDismiss = { showWeekStart = false },
|
||||
)
|
||||
}
|
||||
if (showTimeFormat) {
|
||||
val locale = currentLocale()
|
||||
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_time_format),
|
||||
header = { PickerDescription(stringResource(R.string.settings_time_format_hint)) },
|
||||
@@ -286,18 +299,33 @@ internal fun ViewsScreen(
|
||||
options = TimeFormatPref.entries,
|
||||
selected = state.timeFormat,
|
||||
label = { timeFormatLabel(it) },
|
||||
// Each option renders the same sample time the way it would write
|
||||
// it — an afternoon one, since that is where 12h and 24h diverge.
|
||||
// "Automatic" additionally says which of the two it resolves to now.
|
||||
summary = { pref ->
|
||||
val sample = formatTimeOfDay(
|
||||
hour = SAMPLE_HOUR,
|
||||
minute = SAMPLE_MINUTE,
|
||||
is24Hour = when (pref) {
|
||||
TimeFormatPref.AUTO -> systemIs24Hour
|
||||
TimeFormatPref.TWELVE_HOUR -> false
|
||||
TimeFormatPref.TWENTY_FOUR_HOUR -> true
|
||||
},
|
||||
locale = locale,
|
||||
)
|
||||
if (pref == TimeFormatPref.AUTO) {
|
||||
stringResource(R.string.settings_time_format_auto_summary, sample)
|
||||
} else {
|
||||
sample
|
||||
}
|
||||
},
|
||||
onSelect = viewModel::setTimeFormat,
|
||||
onDismiss = { showTimeFormat = false },
|
||||
)
|
||||
}
|
||||
if (showPastEvents) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
header = { PickerDescription(stringResource(R.string.settings_past_events_hint)) },
|
||||
predictiveBack = true,
|
||||
options = PastEventDisplay.entries,
|
||||
PastEventsPicker(
|
||||
selected = state.pastEventDisplay,
|
||||
label = { pastEventDisplayLabel(it) },
|
||||
onSelect = viewModel::setPastEventDisplay,
|
||||
onDismiss = { showPastEvents = false },
|
||||
)
|
||||
@@ -307,12 +335,19 @@ internal fun ViewsScreen(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.settings_agenda_range_hint),
|
||||
selected = state.agendaScreenRange,
|
||||
// Resolving each option to real dates needs the same week start the
|
||||
// agenda itself windows by.
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
onSelect = viewModel::setAgendaScreenRange,
|
||||
onDismiss = { showAgendaScreenRange = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample time the format options render: afternoon, where 12h and 24h differ. */
|
||||
private const val SAMPLE_HOUR = 14
|
||||
private const val SAMPLE_MINUTE = 30
|
||||
|
||||
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
||||
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
|
||||
@Composable
|
||||
@@ -363,7 +398,7 @@ private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
||||
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
||||
|
||||
@Composable
|
||||
private fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
|
||||
// Localised full weekday name, so any of the seven days reads naturally
|
||||
// without a per-day string resource.
|
||||
@@ -381,10 +416,13 @@ private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResource(
|
||||
when (mode) {
|
||||
PastEventDisplay.SHOW -> R.string.settings_past_events_show
|
||||
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||
},
|
||||
)
|
||||
private fun pastEventDisplayLabel(mode: PastEventDisplay): String =
|
||||
stringResource(pastEventDisplayLabelRes(mode))
|
||||
|
||||
/** Shared with [PastEventsPicker], which needs the id rather than the string. */
|
||||
@StringRes
|
||||
internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode) {
|
||||
PastEventDisplay.SHOW -> R.string.settings_past_events_show
|
||||
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
* The week-start chooser, with the grid it rearranges shown above it.
|
||||
*
|
||||
* Which column a week opens on is the most visual setting in the app, and the
|
||||
* one hardest to hold in your head from a weekday name alone. The preview is
|
||||
* the same [MonthStylePreview] the Month style picker uses, drawn in the user's
|
||||
* *own* month style, so what moves is exactly what will move on the real
|
||||
* screen. It is short here (the option list runs to eight rows), which the
|
||||
* preview supports natively by scaling.
|
||||
*
|
||||
* "Follow the system" carries the day it currently resolves to as its summary —
|
||||
* otherwise the one automatic option is the only one whose effect is invisible.
|
||||
*
|
||||
* A preview picker, so selecting applies at once and keeps the picker open;
|
||||
* back exits (see `FullScreenPicker`).
|
||||
*/
|
||||
@Composable
|
||||
internal fun WeekStartPicker(
|
||||
selected: WeekStartPref,
|
||||
monthStyle: MonthViewStyle,
|
||||
options: List<WeekStartPref>,
|
||||
onSelect: (WeekStartPref) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val resolved = selected.resolveFirstDay(locale)
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.height(PREVIEW_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = resolved,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = "week-start-preview",
|
||||
) { day ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
MonthStylePreview(
|
||||
style = monthStyle,
|
||||
weekStart = day,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PickerDescription(stringResource(R.string.settings_week_start_hint))
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = weekStartLabel(option),
|
||||
// Only the automatic option needs a second line: it is the one
|
||||
// whose effect the label doesn't name.
|
||||
summary = if (option == WeekStartPref.Auto) {
|
||||
stringResource(
|
||||
R.string.settings_week_start_auto_summary,
|
||||
weekStartLabel(WeekStartPref.Day(option.resolveFirstDay(locale))),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(option) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Short enough to leave the first options on screen under it. */
|
||||
private val PREVIEW_HEIGHT = 200.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
@@ -18,15 +18,19 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Widgets & tiles (#69): the home-screen widgets' own settings and the Quick
|
||||
@@ -81,6 +85,7 @@ internal fun WidgetsScreen(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||
selected = state.agendaWidgetRange,
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
onSelect = viewModel::setAgendaWidgetRange,
|
||||
onDismiss = { showAgendaWidgetRange = false },
|
||||
)
|
||||
@@ -93,6 +98,11 @@ internal fun WidgetsScreen(
|
||||
options = WidgetSize.entries,
|
||||
selected = state.widgetSize,
|
||||
label = { widgetSizeLabel(it) },
|
||||
// A size name says nothing about what you get; the text scale it
|
||||
// draws at does. No live preview here on purpose: the widget is
|
||||
// Glance/RemoteViews, so a Compose mock-up would be a second
|
||||
// implementation free to drift from the real thing.
|
||||
summary = { widgetSizeSummary(it) },
|
||||
onSelect = viewModel::setWidgetSize,
|
||||
onDismiss = { showWidgetSize = false },
|
||||
)
|
||||
@@ -127,6 +137,18 @@ private fun requestAddQsTile(context: Context) {
|
||||
) { /* result code unused — the system surfaces its own feedback */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a size step actually does, as a percentage of the smallest one's event
|
||||
* text. Derived from the widget's own [metricsFor] table rather than written
|
||||
* out here, so a retuned step can't leave this line claiming the old number.
|
||||
*/
|
||||
@Composable
|
||||
private fun widgetSizeSummary(size: WidgetSize): String {
|
||||
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
|
||||
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
|
||||
return stringResource(R.string.settings_widget_size_summary, percent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
|
||||
when (size) {
|
||||
|
||||
@@ -155,6 +155,9 @@
|
||||
<!-- Shown in place of the live rule read-out when an amount field holds a
|
||||
value outside 1–999 (i.e. 0), so the greyed-out OK button has a reason. -->
|
||||
<string name="event_edit_recurrence_incomplete">Enter a number from 1 to 999</string>
|
||||
<!-- %1$s is a list of the next few dates, e.g. "30 Jul, 6 Aug and 13 Aug". -->
|
||||
<string name="event_edit_recurrence_next">Next: %1$s</string>
|
||||
<string name="event_edit_recurrence_next_none">This rule never repeats</string>
|
||||
<string name="event_edit_recurrence_ends">Ends</string>
|
||||
<string name="event_edit_recurrence_end_never">Never</string>
|
||||
<string name="event_edit_recurrence_end_until">On a date</string>
|
||||
@@ -198,6 +201,12 @@
|
||||
<string name="event_availability_free">Free</string>
|
||||
<string name="event_access_private">Private</string>
|
||||
<string name="event_access_confidential">Confidential</string>
|
||||
<!-- Second lines in the visibility picker: what each level means to people
|
||||
the calendar is shared with. -->
|
||||
<string name="event_access_default_summary">Whatever this calendar normally does</string>
|
||||
<string name="event_access_public_summary">Everyone with access sees the full details</string>
|
||||
<string name="event_access_private_summary">Others see only that you\'re busy</string>
|
||||
<string name="event_access_confidential_summary">Marked confidential; what that means is up to the calendar account</string>
|
||||
<string name="event_attendee_organizer">Organizer</string>
|
||||
<string name="event_attendee_optional">Optional</string>
|
||||
<string name="event_attendee_resource">Resource</string>
|
||||
@@ -315,6 +324,9 @@
|
||||
<string name="settings_theme_system">System</string>
|
||||
<string name="settings_theme_light">Light</string>
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_theme_hint">Whether the app is light or dark. The choice applies immediately.</string>
|
||||
<!-- %1$s is the theme the system currently resolves to, e.g. "Dark". -->
|
||||
<string name="settings_theme_system_summary">Currently %1$s</string>
|
||||
<string name="settings_default_view">Default view</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
@@ -329,8 +341,14 @@
|
||||
<string name="settings_font_choose_file">Choose file…</string>
|
||||
<string name="settings_font_custom_selected">Custom font</string>
|
||||
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
|
||||
<!-- Specimen line shown in the headings-font picker, set in the chosen face. -->
|
||||
<string name="settings_font_specimen_heading">Thursday, 14 May</string>
|
||||
<!-- Specimen paragraph shown in the body-font picker, set in the chosen face. -->
|
||||
<string name="settings_font_specimen_body">Team review at 10:00, then lunch with Robin at the café on Marktplatz.</string>
|
||||
<string name="settings_week_start">Week starts on</string>
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<!-- %1$s is the weekday the automatic setting currently resolves to, e.g. "Monday". -->
|
||||
<string name="settings_week_start_auto_summary">Currently %1$s</string>
|
||||
<string name="settings_week_numbers">Week numbers</string>
|
||||
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
||||
<string name="settings_today_toolbar">Today button in toolbar</string>
|
||||
@@ -341,6 +359,8 @@
|
||||
<string name="settings_time_format_auto">Automatic</string>
|
||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||
<!-- %1$s is a sample time written the way the system currently writes it. -->
|
||||
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
|
||||
<string name="settings_hour_lines">Hour lines</string>
|
||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||
<string name="settings_dim_completed">Dim completed events</string>
|
||||
@@ -349,6 +369,10 @@
|
||||
<string name="settings_past_events_show">Show</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
<string name="settings_past_events_hide">Hide</string>
|
||||
<!-- Stand-in event titles in the past-events preview. Short, everyday entries. -->
|
||||
<string name="settings_past_events_sample_morning">Team review</string>
|
||||
<string name="settings_past_events_sample_midday">Lunch with Robin</string>
|
||||
<string name="settings_past_events_sample_evening">Choir practice</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Agenda range</string>
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
@@ -361,6 +385,8 @@
|
||||
<string name="settings_widget_size_medium">Medium</string>
|
||||
<string name="settings_widget_size_large">Large</string>
|
||||
<string name="settings_widget_size_extra_large">Extra large</string>
|
||||
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
|
||||
<string name="settings_widget_size_summary">Event text %1$d%%</string>
|
||||
<string name="settings_agenda_show_today">Always show today</string>
|
||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
@@ -411,6 +437,8 @@
|
||||
<string name="settings_default_reminder_allday">All-day events</string>
|
||||
<string name="settings_allday_reminder_time">All-day reminder time</string>
|
||||
<string name="settings_allday_reminder_time_hint">Reminders for all-day events fire at %1$s</string>
|
||||
<!-- Second line on an all-day lead-time row. %1$s is a clock time, e.g. "09:00". -->
|
||||
<string name="settings_allday_reminder_fires_at">Notifies at %1$s</string>
|
||||
<string name="reminder_none">None</string>
|
||||
<string name="reminder_use_default">Use default reminder</string>
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
@@ -434,6 +462,8 @@
|
||||
<string name="settings_group_look">Look & behaviour</string>
|
||||
<string name="settings_group_data">Data</string>
|
||||
<string name="settings_group_app">App</string>
|
||||
<!-- Group of reference links at the foot of the settings hub. -->
|
||||
<string name="settings_group_about">About</string>
|
||||
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Theme, colours, fonts</string>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RecurrenceOccurrencesTest {
|
||||
|
||||
private fun date(year: Int, month: Int, day: Int) = LocalDate(year, month, day)
|
||||
|
||||
@Test
|
||||
fun `daily rule starts at the event's own date`() {
|
||||
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily)
|
||||
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 30),
|
||||
date(2026, 7, 31),
|
||||
date(2026, 8, 1),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interval multiplies the period`() {
|
||||
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily, interval = 3)
|
||||
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 30),
|
||||
date(2026, 8, 2),
|
||||
date(2026, 8, 5),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly without weekday picks repeats the start's weekday`() {
|
||||
// 30 Jul 2026 is a Thursday.
|
||||
val occurrences = SimpleRecurrence(RecurrenceFreq.Weekly)
|
||||
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 30),
|
||||
date(2026, 8, 6),
|
||||
date(2026, 8, 13),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly with picks fires on every chosen day, in weekday order`() {
|
||||
val occurrences = SimpleRecurrence(
|
||||
RecurrenceFreq.Weekly,
|
||||
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY),
|
||||
).upcomingOccurrences(date(2026, 7, 30), limit = 4)
|
||||
|
||||
// Thursday start: the Monday of that week is already past, so the first
|
||||
// hit is Friday 31 Jul, then Mon/Fri of the following weeks.
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 31),
|
||||
date(2026, 8, 3),
|
||||
date(2026, 8, 7),
|
||||
date(2026, 8, 10),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly interval skips whole weeks, counted from Monday`() {
|
||||
val occurrences = SimpleRecurrence(
|
||||
RecurrenceFreq.Weekly,
|
||||
interval = 2,
|
||||
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.THURSDAY),
|
||||
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||
|
||||
// Start week (27 Jul–2 Aug) contributes only its Thursday; the next block
|
||||
// is two weeks on, so 10 Aug and 13 Aug — not 3 Aug.
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 30),
|
||||
date(2026, 8, 10),
|
||||
date(2026, 8, 13),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly skips months without the start day rather than clamping`() {
|
||||
val occurrences = SimpleRecurrence(RecurrenceFreq.Monthly)
|
||||
.upcomingOccurrences(date(2026, 1, 31), limit = 4)
|
||||
|
||||
// February, April and June have no 31st — the rule passes them by.
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 1, 31),
|
||||
date(2026, 3, 31),
|
||||
date(2026, 5, 31),
|
||||
date(2026, 7, 31),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yearly on 29 February only lands in leap years`() {
|
||||
val occurrences = SimpleRecurrence(RecurrenceFreq.Yearly)
|
||||
.upcomingOccurrences(date(2024, 2, 29), limit = 3)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2024, 2, 29),
|
||||
date(2028, 2, 29),
|
||||
date(2032, 2, 29),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `count limits the series and skipped periods don't consume one`() {
|
||||
val occurrences = SimpleRecurrence(
|
||||
RecurrenceFreq.Monthly,
|
||||
end = RecurrenceEnd.Count(3),
|
||||
).upcomingOccurrences(date(2026, 1, 31), limit = 10)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 1, 31),
|
||||
date(2026, 3, 31),
|
||||
date(2026, 5, 31),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `until is inclusive of its own date`() {
|
||||
val occurrences = SimpleRecurrence(
|
||||
RecurrenceFreq.Daily,
|
||||
end = RecurrenceEnd.Until(date(2026, 8, 1)),
|
||||
).upcomingOccurrences(date(2026, 7, 30), limit = 10)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 30),
|
||||
date(2026, 7, 31),
|
||||
date(2026, 8, 1),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an until before the start yields nothing instead of spinning`() {
|
||||
val occurrences = SimpleRecurrence(
|
||||
RecurrenceFreq.Daily,
|
||||
end = RecurrenceEnd.Until(date(2026, 7, 1)),
|
||||
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||
|
||||
assertThat(occurrences).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run inside one year needs no year, one that leaves it does`() {
|
||||
val start = date(2026, 7, 30)
|
||||
val withinYear = SimpleRecurrence(RecurrenceFreq.Daily).upcomingOccurrences(start, limit = 3)
|
||||
assertThat(occurrencesSpanYears(withinYear, start)).isFalse()
|
||||
|
||||
// Yearly: same day and month every time, so the year is the only thing
|
||||
// telling the three dates apart.
|
||||
val yearly = SimpleRecurrence(RecurrenceFreq.Yearly).upcomingOccurrences(start, limit = 3)
|
||||
assertThat(occurrencesSpanYears(yearly, start)).isTrue()
|
||||
|
||||
// Monthly rolling past December.
|
||||
val newYearStart = date(2026, 11, 30)
|
||||
val overflowing = SimpleRecurrence(RecurrenceFreq.Monthly)
|
||||
.upcomingOccurrences(newYearStart, limit = 3)
|
||||
assertThat(overflowing).containsExactly(
|
||||
date(2026, 11, 30),
|
||||
date(2026, 12, 30),
|
||||
date(2027, 1, 30),
|
||||
).inOrder()
|
||||
assertThat(occurrencesSpanYears(overflowing, newYearStart)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `limit and count are both respected, whichever is smaller`() {
|
||||
val rule = SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Count(2))
|
||||
|
||||
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 5)).hasSize(2)
|
||||
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 1)).hasSize(1)
|
||||
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 0)).isEmpty()
|
||||
}
|
||||
}
|
||||
Submodule floret-kit updated: b4d3ead8f0...ed1d3ca5e8
Reference in New Issue
Block a user