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:
2026-07-30 21:12:59 +02:00
parent 08fd3467ad
commit 85ebbc9952
20 changed files with 1111 additions and 96 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (1365).
* 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) {

View File

@@ -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()) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -155,6 +155,9 @@
<!-- Shown in place of the live rule read-out when an amount field holds a
value outside 1999 (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 &amp; 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>

View File

@@ -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 Jul2 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()
}
}