From 85ebbc9952a2729ba5705d425c01cec4eb01d1d8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 21:12:59 +0200 Subject: [PATCH] Show what a setting does, not just what it's called MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../calendula/domain/RecurrenceOccurrences.kt | 122 +++++++++++ .../calendula/ui/agenda/AgendaScreen.kt | 2 + .../calendula/ui/agenda/AgendaViewModel.kt | 10 +- .../calendula/ui/calendars/CalendarsScreen.kt | 11 +- .../calendula/ui/common/Picker.kt | 43 +++- .../calendula/ui/common/RecurrenceText.kt | 42 ++++ .../calendula/ui/common/TimeZonePicker.kt | 8 +- .../calendula/ui/edit/EventEditScreen.kt | 48 +++- .../ui/settings/AppearanceSettings.kt | 85 ++++++-- .../ui/settings/NotificationSettings.kt | 19 +- .../calendula/ui/settings/PastEventsPicker.kt | 206 ++++++++++++++++++ .../calendula/ui/settings/SettingsCommon.kt | 60 ++++- .../calendula/ui/settings/SettingsScreen.kt | 114 +++++++--- .../ui/settings/SpecialDatesSettings.kt | 6 + .../calendula/ui/settings/ViewsSettings.kt | 78 +++++-- .../calendula/ui/settings/WeekStartPicker.kt | 121 ++++++++++ .../calendula/ui/settings/WidgetSettings.kt | 22 ++ app/src/main/res/values/strings.xml | 30 +++ .../domain/RecurrenceOccurrencesTest.kt | 178 +++++++++++++++ floret-kit | 2 +- 20 files changed, 1111 insertions(+), 96 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrences.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrencesTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrences.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrences.kt new file mode 100644 index 0000000..0095c85 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrences.kt @@ -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 { + 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() + 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 = + 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 { + 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, 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 83006f0..2abaa99 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -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 }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index af8c38b..e288003 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -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 = 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(null) val state: StateFlow = - 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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index ff9b8be..c9f3508 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -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), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index a5fcb2f..d0fe640 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt index 2ce12c9..fb3a582 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceText.kt @@ -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()) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index 6400212..fad9d51 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -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, + ), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index f3c36bb..93105a4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt index 62bf5c3..37645e6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index f468ec9..2242e41 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt new file mode 100644 index 0000000..049ed11 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/PastEventsPicker.kt @@ -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): List = + 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt index 90572c6..cb36a77 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsCommon.kt @@ -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): 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 } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 89776d8..4478b73 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -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) }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt index 81e180d..5627285 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt @@ -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), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index 9abe628..5b72f3a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -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 = 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt new file mode 100644 index 0000000..8a04727 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt @@ -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, + 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt index 8bcfbbc..e0cf871 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9df6fe..cedc6ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -155,6 +155,9 @@ Enter a number from 1 to 999 + + Next: %1$s + This rule never repeats Ends Never On a date @@ -198,6 +201,12 @@ Free Private Confidential + + Whatever this calendar normally does + Everyone with access sees the full details + Others see only that you\'re busy + Marked confidential; what that means is up to the calendar account Organizer Optional Resource @@ -315,6 +324,9 @@ System Light Dark + Whether the app is light or dark. The choice applies immediately. + + Currently %1$s Default view Dynamic colour Requires Android 12 or newer @@ -329,8 +341,14 @@ Choose file… Custom font Couldn\'t read that file as a font + + Thursday, 14 May + + Team review at 10:00, then lunch with Robin at the café on Marktplatz. Week starts on Automatic + + Currently %1$s Week numbers Show calendar-week numbers in month view Today button in toolbar @@ -341,6 +359,8 @@ Automatic 12-hour (2:00 PM) 24-hour (14:00) + + Following the system: %1$s Hour lines Show a separator line at each hour in week and day view Dim completed events @@ -349,6 +369,10 @@ Show Dim Hide + + Team review + Lunch with Robin + Choir practice Agenda Agenda range How far ahead the Agenda screen lists events. @@ -361,6 +385,8 @@ Medium Large Extra large + + Event text %1$d%% Always show today Keep today at the top of the agenda and its widget, even once nothing is left today. Range bar @@ -411,6 +437,8 @@ All-day events All-day reminder time Reminders for all-day events fire at %1$s + + Notifies at %1$s None Use default reminder Amount @@ -434,6 +462,8 @@ Look & behaviour Data App + + About Theme, colours, fonts diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrencesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrencesTest.kt new file mode 100644 index 0000000..50a6729 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/RecurrenceOccurrencesTest.kt @@ -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() + } +} diff --git a/floret-kit b/floret-kit index b4d3ead..ed1d3ca 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit b4d3ead8f0fe1e042c1cd7f350ff7132f22189b3 +Subproject commit ed1d3ca5e8ff0d63e2ed9d7ece25bcfbcd3be02d