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