From ccd433fee104c22e5cd126100089d24154faa09e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 19 Jul 2026 16:56:55 +0200 Subject: [PATCH 1/5] refactor(edit): redesign the custom recurrence picker (#42) Rebuild the custom step of the recurrence picker so complex-but-supported rules (e.g. "every 2 weeks on Mon+Tue") are discoverable and legible, rather than buried in a ragged stack of mismatched controls. - Add a live, human-readable summary of the rule as it is built, via the existing recurrenceText humanizer. - Group interval + frequency into one tonal card; the frequency is now a SingleChoiceSegmentedButtonRow (all four units visible) instead of a dropdown. - Reveal the weekday picks with AnimatedVisibility and house them, the "every" controls, and the ends group in consistent 16dp-inset grouped cards; the count field folds into the bottom of the ends run. No behaviour, domain, or RRULE changes: parse/render logic and the set of expressible rules are unchanged. The EXDATE half of #42 is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/edit/EventEditScreen.kt | 217 ++++++++++++------ 1 file changed, 144 insertions(+), 73 deletions(-) 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 3f5d920..5eeee1f 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 @@ -1382,84 +1382,154 @@ private fun RecurrencePickerDialog( ) } else { Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(horizontal = 24.dp), + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.event_edit_recurrence_every), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.width(12.dp)) - DialogAmountField( - value = intervalText, - onValueChange = { intervalText = it }, - placeholder = "1", - ) - Spacer(Modifier.width(12.dp)) - DialogUnitDropdown( - label = stringResource(recurrenceUnitLabel(freq)), - entries = RecurrenceFreq.entries.map { - stringResource(recurrenceUnitLabel(it)) - }, - onPick = { freq = RecurrenceFreq.entries[it] }, - ) - } - if (freq == RecurrenceFreq.Weekly) { - WeekdayToggleRow( - selected = daysMask.toDaySet(), - onToggle = { day -> daysMask = daysMask xor day.toMaskBit() }, - locale = locale, - ) - } + // A live, human-readable read-out of the rule being built, so the + // effect of the controls below is never a guess. Text( - text = stringResource(R.string.event_edit_recurrence_ends), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = recurrenceText( + SimpleRecurrence( + freq = freq, + interval = interval ?: 1, + end = customEnd ?: RecurrenceEnd.Never, + byDays = if (freq == RecurrenceFreq.Weekly) { + daysMask.toDaySet() + } else { + emptySet() + }, + ).toRRule(), + locale, + ), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) - } - GroupedRow( - title = stringResource(R.string.event_edit_recurrence_end_never), - position = positionOf(0, 3), - selected = endMode == RecurrenceEndMode.Never, - onClick = { endMode = RecurrenceEndMode.Never }, - ) - GroupedRow( - title = stringResource(R.string.event_edit_recurrence_end_until), - summary = untilDate?.let { - remember(it, locale) { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(locale).format(it.toJavaLocalDate()) - } - }, - position = positionOf(1, 3), - selected = endMode == RecurrenceEndMode.Until, - onClick = { - endMode = RecurrenceEndMode.Until - showUntilPicker = true - }, - ) - GroupedRow( - title = stringResource(R.string.event_edit_recurrence_end_count), - position = positionOf(2, 3), - selected = endMode == RecurrenceEndMode.Count, - onClick = { endMode = RecurrenceEndMode.Count }, - ) - if (endMode == RecurrenceEndMode.Count) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 24.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. + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(Position.Alone), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) { - DialogAmountField( - value = countText, - onValueChange = { countText = it }, - placeholder = "10", - ) - Spacer(Modifier.width(12.dp)) + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.event_edit_recurrence_every), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.width(12.dp)) + DialogAmountField( + value = intervalText, + onValueChange = { intervalText = it }, + placeholder = "1", + ) + } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + RecurrenceFreq.entries.forEachIndexed { index, entry -> + SegmentedButton( + selected = freq == entry, + onClick = { freq = entry }, + shape = SegmentedButtonDefaults.itemShape( + index, RecurrenceFreq.entries.size, + ), + label = { Text(stringResource(recurrenceUnitLabel(entry))) }, + ) + } + } + } + } + + // Weekday picks — only meaningful on a weekly rule. + AnimatedVisibility(visible = freq == RecurrenceFreq.Weekly) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(Position.Alone), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + WeekdayToggleRow( + selected = daysMask.toDaySet(), + onToggle = { day -> daysMask = daysMask xor day.toMaskBit() }, + locale = locale, + modifier = Modifier.padding(16.dp), + ) + } + } + + // Ends: a connected never / on-a-date / after-N group, the count + // field folding into the bottom of the run when chosen. + Column { Text( - text = stringResource(R.string.event_edit_recurrence_times), - style = MaterialTheme.typography.titleMedium, + text = stringResource(R.string.event_edit_recurrence_ends), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp), ) + GroupedRow( + title = stringResource(R.string.event_edit_recurrence_end_never), + position = Position.Top, + selected = endMode == RecurrenceEndMode.Never, + onClick = { endMode = RecurrenceEndMode.Never }, + ) + GroupedRow( + title = stringResource(R.string.event_edit_recurrence_end_until), + summary = untilDate?.let { + remember(it, locale) { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(locale).format(it.toJavaLocalDate()) + } + }, + position = Position.Middle, + selected = endMode == RecurrenceEndMode.Until, + onClick = { + endMode = RecurrenceEndMode.Until + showUntilPicker = true + }, + ) + GroupedRow( + title = stringResource(R.string.event_edit_recurrence_end_count), + position = if (endMode == RecurrenceEndMode.Count) { + Position.Middle + } else { + Position.Bottom + }, + selected = endMode == RecurrenceEndMode.Count, + onClick = { endMode = RecurrenceEndMode.Count }, + ) + if (endMode == RecurrenceEndMode.Count) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(Position.Bottom), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp), + ) { + DialogAmountField( + value = countText, + onValueChange = { countText = it }, + placeholder = "10", + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.event_edit_recurrence_times), + style = MaterialTheme.typography.titleMedium, + ) + } + } + } } } } @@ -1489,6 +1559,7 @@ private fun WeekdayToggleRow( selected: Set, onToggle: (DayOfWeek) -> Unit, locale: Locale, + modifier: Modifier = Modifier, ) { val days = remember(locale) { val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek() @@ -1496,7 +1567,7 @@ private fun WeekdayToggleRow( } Row( horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), ) { days.forEach { day -> val isSelected = day in selected -- 2.49.1 From 722fc8725935ac45634c6949cf8ca95b6156f045 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 13:04:57 +0200 Subject: [PATCH 2/5] fix(edit): honour week-start pref in recurrence picker + smooth weekday collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The weekday toggles now order by the app's "Week starts on" setting (via WeekStartPref.resolveFirstDay), matching the month/week/agenda views, instead of always following the device locale. Threaded through a new EventEditViewModel.firstDayOfWeek flow. - Carry the 16dp section gaps on each block rather than a parent spacedBy, so the weekday card's gap collapses together with the card under AnimatedVisibility — removing the end-of-animation jump in the ends section when switching away from Weekly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/edit/EventEditScreen.kt | 27 +++++++++++-------- .../calendula/ui/edit/EventEditViewModel.kt | 17 ++++++++++++ 2 files changed, 33 insertions(+), 11 deletions(-) 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 5eeee1f..aa569b0 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 @@ -163,14 +163,12 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.isoDayNumber import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate -import kotlinx.datetime.toKotlinDayOfWeek import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle -import java.time.temporal.WeekFields import java.util.Locale import kotlin.time.Clock @@ -502,6 +500,7 @@ private fun EventEditContent( ) { val form = state.form val locale = currentLocale() + val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle() val dark = isSystemInDarkTheme() // The title, date and recurrence are managed by the special-dates sync, so // they're locked here; everything else (reminders, location, notes) is the @@ -1122,6 +1121,7 @@ private fun EventEditContent( RecurrencePickerDialog( current = form.rrule, startDay = form.start.date.dayOfWeek, + firstDayOfWeek = firstDayOfWeek, onSelect = { rrule -> viewModel.setRecurrence(rrule) showRecurrencePicker = false @@ -1293,6 +1293,7 @@ private enum class RecurrenceEndMode { Never, Until, Count } private fun RecurrencePickerDialog( current: String?, startDay: DayOfWeek, + firstDayOfWeek: DayOfWeek, onSelect: (String?) -> Unit, onDismiss: () -> Unit, ) { @@ -1381,10 +1382,10 @@ private fun RecurrencePickerDialog( onClick = { customMode = true }, ) } else { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + // Section gaps live on each block (not a parent spacedBy) so the + // weekday card's gap collapses *with* it under AnimatedVisibility — + // a parent arrangement would leave a residual gap that snaps shut. + Column(modifier = Modifier.fillMaxWidth()) { // A live, human-readable read-out of the rule being built, so the // effect of the controls below is never a guess. Text( @@ -1414,6 +1415,7 @@ private fun RecurrencePickerDialog( shape = groupedShape(Position.Alone), modifier = Modifier .fillMaxWidth() + .padding(top = 16.dp) .padding(horizontal = 16.dp), ) { Column( @@ -1447,19 +1449,22 @@ private fun RecurrencePickerDialog( } } - // Weekday picks — only meaningful on a weekly rule. + // Weekday picks — only meaningful on a weekly rule. The 16dp gap + // sits inside the animated block so it grows/shrinks with the card. AnimatedVisibility(visible = freq == RecurrenceFreq.Weekly) { Surface( color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = groupedShape(Position.Alone), modifier = Modifier .fillMaxWidth() + .padding(top = 16.dp) .padding(horizontal = 16.dp), ) { WeekdayToggleRow( selected = daysMask.toDaySet(), onToggle = { day -> daysMask = daysMask xor day.toMaskBit() }, locale = locale, + firstDay = firstDayOfWeek, modifier = Modifier.padding(16.dp), ) } @@ -1467,7 +1472,7 @@ private fun RecurrencePickerDialog( // Ends: a connected never / on-a-date / after-N group, the count // field folding into the bottom of the run when chosen. - Column { + Column(modifier = Modifier.padding(top = 16.dp)) { Text( text = stringResource(R.string.event_edit_recurrence_ends), style = MaterialTheme.typography.labelLarge, @@ -1559,11 +1564,11 @@ private fun WeekdayToggleRow( selected: Set, onToggle: (DayOfWeek) -> Unit, locale: Locale, + firstDay: DayOfWeek, modifier: Modifier = Modifier, ) { - val days = remember(locale) { - val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek() - (0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) } + val days = remember(firstDay) { + (0 until 7).map { DayOfWeek(((firstDay.isoDayNumber - 1 + it) % 7) + 1) } } Row( horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index b339f39..dc4e469 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -8,7 +8,9 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource @@ -40,12 +42,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime +import java.util.Locale import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock import kotlin.time.Duration.Companion.hours @@ -267,6 +271,19 @@ class EventEditViewModel @Inject constructor( initialValue = null, ) + /** + * First day of the week for ordering the recurrence weekday toggles — the + * app's "week starts on" preference, matching the calendar views (Auto + * falls back to the locale convention). + */ + val firstDayOfWeek: StateFlow = settingsPrefs.weekStart + .map { it.resolveFirstDay(Locale.getDefault()) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()), + ) + /** * Initialise a fresh form for a new event on [date]. [startMinutes] (minutes * from midnight) anchors the start when the form is opened by tapping a slot -- 2.49.1 From 98d76deee53f17f55a24a28ce27e0b9185a0052d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 15:20:15 +0200 Subject: [PATCH 3/5] fix(edit): correct the recurrence picker's read-out and end handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups to the picker redesign, plus the bugs it exposed. The live read-out now renders customResult itself — the exact string OK would save — instead of rebuilding a parallel rule with `interval ?: 1` and `end ?: Never` fallbacks, which let it confidently describe a rule that differed from the selected controls whenever a field was invalid. Shrink the invalid space behind it: a blank amount field reads as its visible placeholder (1 / 10) rather than as an error, and backing out of the date picker falls back to "never" instead of stranding a dateless "on a date". Only an out-of-range 0 remains invalid, and that now says so rather than greying out OK with no cause. UNTIL displayed the day after the one picked for zones behind UTC: toRRule deliberately writes the end of the chosen *local* day expressed in UTC (the provider applies UNTIL coarsely), so the read side must convert back before taking the date. Fixes the detail screen too, and untilLocalDate is extracted so it can be tested. Also: hoist a remember() out of a conditional (a slot that appears and disappears breaks positional memoisation), match GroupedSurface's 22dp corners instead of a drifted local 20dp copy, move the cards onto GroupedSurface, drop the segmented row's icon slot so longer unit labels fit, and reserve two lines so the stack stops shifting as the phrase grows with each weekday. Extract SettingsPrefs.firstDayOfWeek(scope), replacing three copies that had drifted onto different initialValues. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/data/prefs/SettingsPrefs.kt | 22 +++ .../calendula/ui/agenda/AgendaViewModel.kt | 7 +- .../calendula/ui/common/RecurrenceText.kt | 40 ++++-- .../calendula/ui/edit/EventEditScreen.kt | 125 ++++++++++-------- .../calendula/ui/edit/EventEditViewModel.kt | 12 +- .../calendula/ui/month/MonthViewModel.kt | 13 +- app/src/main/res/values/strings.xml | 3 + .../calendula/ui/common/RecurrenceTextTest.kt | 69 ++++++++++ 8 files changed, 205 insertions(+), 86 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 1026f2e..316e33d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -23,8 +23,12 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import java.time.ZoneId +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DayOfWeek import java.time.temporal.WeekFields import java.util.Locale @@ -82,6 +86,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) { WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value) } +/** + * The resolved first day of the week as a hot [StateFlow] — the one shape every + * screen that orders or lays out weekdays should use (the month grid, the agenda + * "this week" range, the recurrence weekday toggles). Sharing it keeps the + * initial-frame value identical everywhere; hand-rolled copies had drifted onto + * different `initialValue`s, so two surfaces could disagree for a frame. + */ +fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow = + weekStart + .map { it.resolveFirstDay(Locale.getDefault()) } + .stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(5_000L), + // Seed with the locale convention rather than a hardcoded weekday, so + // the first frame is already right for everyone still on Auto. + initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()), + ) + /** * Display settings (M4) persisted app-side: theme override, Material You * dynamic colour, and week start. Language is handled separately through 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 924f6b8..bc507c3 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 @@ -7,7 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone -import java.util.Locale import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime import kotlinx.datetime.plus @@ -51,8 +49,7 @@ class AgendaViewModel @Inject constructor( ) { range, showBar -> AgendaSettings(range, showBar) } // First day of the week, for the calendar-aligned "this week" range. - private val weekStartDay = settingsPrefs.weekStart - .map { it.resolveFirstDay(Locale.getDefault()) } + private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope) /** * How to treat events that already ended today (show / dim / hide). A display 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 2c930b8..2ce12c9 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 @@ -9,6 +9,10 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import de.jeanlucmakiola.calendula.R import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.format.TextStyle as JavaTextStyle @@ -111,16 +115,36 @@ private fun rruleDayName(token: String, locale: Locale): String? { /** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */ private fun parseUntilDate(raw: String, locale: Locale): String? { - val digits = raw.takeWhile { it.isDigit() } - if (digits.length < 8) return null - return try { - val date = java.time.LocalDate.of( + val date = untilLocalDate(raw) ?: return null + return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date) +} + +private val UNTIL_UTC_FORMAT: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT) + +/** + * The calendar day an RRULE UNTIL value denotes, *in the device's zone*. + * + * The UTC form ("20261231T225959Z") must be converted back before its date is + * read: `SimpleRecurrence.toRRule` writes the end of the chosen **local** day + * expressed in UTC, so for zones behind UTC that instant already falls on the + * following UTC date. Taking the leading digits straight off would then show + * the day after the one the user picked. Date-only and floating forms are + * already local and pass through unchanged. + */ +internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): LocalDate? { + val value = raw.trim() + return runCatching { + LocalDateTime.parse(value, UNTIL_UTC_FORMAT) + .atOffset(ZoneOffset.UTC) + .atZoneSameInstant(zone) + .toLocalDate() + }.recoverCatching { + val digits = value.takeWhile { it.isDigit() } + LocalDate.of( digits.substring(0, 4).toInt(), digits.substring(4, 6).toInt(), digits.substring(6, 8).toInt(), ) - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date) - } catch (e: Exception) { - null - } + }.getOrNull() } 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 aa569b0..13dd8ed 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 @@ -99,7 +99,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -140,11 +139,13 @@ import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSurface import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert @@ -1328,8 +1329,21 @@ private fun RecurrencePickerDialog( val locale = currentLocale() val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } - val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 } - val count = countText.toIntOrNull()?.takeIf { it in 1..999 } + // remember() must not sit behind the ?. — a slot that appears and disappears + // as a date is picked breaks Compose's positional memoisation. Format + // unconditionally and let the null fall through to a null summary. + val untilSummary = remember(untilDate, locale) { + untilDate?.let { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(locale).format(it.toJavaLocalDate()) + } + } + // A blank amount field shows its placeholder ("1" / "10") greyed out, so read + // blank as that default rather than as an error — otherwise simply clearing + // the field silently greys out OK. Only a real out-of-range value (0) is + // invalid, and that's the one case the read-out below reports. + val interval = if (intervalText.isBlank()) 1 else intervalText.toIntOrNull()?.takeIf { it in 1..999 } + val count = if (countText.isBlank()) 10 else countText.toIntOrNull()?.takeIf { it in 1..999 } val customEnd: RecurrenceEnd? = when (endMode) { RecurrenceEndMode.Never -> RecurrenceEnd.Never RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) } @@ -1387,22 +1401,25 @@ private fun RecurrencePickerDialog( // a parent arrangement would leave a residual gap that snaps shut. Column(modifier = Modifier.fillMaxWidth()) { // A live, human-readable read-out of the rule being built, so the - // effect of the controls below is never a guess. + // effect of the controls below is never a guess. It renders + // *[customResult] itself* — the exact string OK would save — so it + // can never describe a different rule than the one that lands. When + // the form can't produce a rule, it says why instead of falling + // back to defaults and quietly disagreeing with the controls. + // minLines reserves the second line up front: without it the whole + // stack below shifts a line as the phrase grows with each weekday. Text( - text = recurrenceText( - SimpleRecurrence( - freq = freq, - interval = interval ?: 1, - end = customEnd ?: RecurrenceEnd.Never, - byDays = if (freq == RecurrenceFreq.Weekly) { - daysMask.toDaySet() - } else { - emptySet() - }, - ).toRRule(), - locale, - ), + text = customResult?.let { recurrenceText(it, locale) } + ?: AnnotatedString( + stringResource(R.string.event_edit_recurrence_incomplete), + ), style = MaterialTheme.typography.titleMedium, + color = if (customResult != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.error + }, + minLines = 2, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -1410,11 +1427,9 @@ private fun RecurrencePickerDialog( // How often: an interval amount plus a frequency segmented row — // all four units on show, no unit hidden behind a dropdown. - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = groupedShape(Position.Alone), + GroupedSurface( + position = Position.Alone, modifier = Modifier - .fillMaxWidth() .padding(top = 16.dp) .padding(horizontal = 16.dp), ) { @@ -1442,7 +1457,18 @@ private fun RecurrencePickerDialog( shape = SegmentedButtonDefaults.itemShape( index, RecurrenceFreq.entries.size, ), - label = { Text(stringResource(recurrenceUnitLabel(entry))) }, + // Drop the default check-icon slot: four segments + // share one row, and its reserved width is what + // pushes longer labels ("Monate", "Wochen") into + // an ellipsis on a compact screen. Selection is + // already carried by the segment's fill. + icon = {}, + label = { + Text( + text = stringResource(recurrenceUnitLabel(entry)), + maxLines = 1, + ) + }, ) } } @@ -1452,11 +1478,9 @@ private fun RecurrencePickerDialog( // Weekday picks — only meaningful on a weekly rule. The 16dp gap // sits inside the animated block so it grows/shrinks with the card. AnimatedVisibility(visible = freq == RecurrenceFreq.Weekly) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = groupedShape(Position.Alone), + GroupedSurface( + position = Position.Alone, modifier = Modifier - .fillMaxWidth() .padding(top = 16.dp) .padding(horizontal = 16.dp), ) { @@ -1487,12 +1511,7 @@ private fun RecurrencePickerDialog( ) GroupedRow( title = stringResource(R.string.event_edit_recurrence_end_until), - summary = untilDate?.let { - remember(it, locale) { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(locale).format(it.toJavaLocalDate()) - } - }, + summary = untilSummary, position = Position.Middle, selected = endMode == RecurrenceEndMode.Until, onClick = { @@ -1510,13 +1529,13 @@ private fun RecurrencePickerDialog( selected = endMode == RecurrenceEndMode.Count, onClick = { endMode = RecurrenceEndMode.Count }, ) - if (endMode == RecurrenceEndMode.Count) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = groupedShape(Position.Bottom), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + // Animated for the same reason the weekday card is: this is the + // picker's other collapsible block, and a bare `if` here made + // the ends group snap while the card above it glided. + AnimatedVisibility(visible = endMode == RecurrenceEndMode.Count) { + GroupedSurface( + position = Position.Bottom, + modifier = Modifier.padding(horizontal = 16.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -1549,7 +1568,13 @@ private fun RecurrencePickerDialog( untilIso = it.toString() showUntilPicker = false }, - onDismiss = { showUntilPicker = false }, + onDismiss = { + showUntilPicker = false + // Backing out of the date picker with nothing chosen would leave + // "on date" selected but dateless — a state that can't be saved and + // greys out OK with no visible cause. Fall back to "never". + if (untilIso == null) endMode = RecurrenceEndMode.Never + }, ) } } @@ -1625,18 +1650,14 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) { RecurrenceFreq.Yearly -> R.string.recurrence_unit_years } -/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */ -private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape = - when (position) { - Position.Alone -> RoundedCornerShape(full) - Position.Top -> RoundedCornerShape( - topStart = full, topEnd = full, bottomStart = small, bottomEnd = small, - ) - Position.Middle -> RoundedCornerShape(small) - Position.Bottom -> RoundedCornerShape( - topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, - ) - } +/** + * Corner shape for a card at [position] in a grouped run. Delegates to the + * family primitive with the same radii [GroupedSurface] draws at rest — a local + * copy had drifted to 20dp, so a card sharing a run with GroupedRows had + * visibly tighter outer corners than the rows above it. + */ +private fun groupedShape(position: Position): Shape = + floretGroupedShape(position, full = 22.dp, small = 6.dp) /** The 2dp gap that visually separates grouped cards (none after the last). */ private fun groupedGap(position: Position): Modifier = when (position) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index dc4e469..45ae36f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -8,9 +8,8 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref +import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder -import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource @@ -49,7 +48,6 @@ import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime -import java.util.Locale import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock import kotlin.time.Duration.Companion.hours @@ -276,13 +274,7 @@ class EventEditViewModel @Inject constructor( * app's "week starts on" preference, matching the calendar views (Auto * falls back to the locale convention). */ - val firstDayOfWeek: StateFlow = settingsPrefs.weekStart - .map { it.resolveFirstDay(Locale.getDefault()) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()), - ) + val firstDayOfWeek: StateFlow = settingsPrefs.firstDayOfWeek(viewModelScope) /** * Initialise a fresh form for a new event on [date]. [startMinutes] (minutes diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index de51d4e..8abd2f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek @@ -34,7 +33,6 @@ import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime -import java.util.Locale import kotlin.time.Clock import kotlin.time.Instant import javax.inject.Inject @@ -48,16 +46,9 @@ class MonthViewModel @Inject constructor( ) : ViewModel() { private val zone = TimeZone.currentSystemDefault() - private val locale: Locale = Locale.getDefault() /** First day of the week, from the Settings preference (AUTO → locale). */ - val weekStart: StateFlow = settingsPrefs.weekStart - .map { it.resolveFirstDay(locale) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = DayOfWeek.MONDAY, - ) + val weekStart: StateFlow = settingsPrefs.firstDayOfWeek(viewModelScope) /** Whether to fade events that have already finished (display concern only). */ val dimCompletedEvents: StateFlow = settingsPrefs.dimCompletedEvents diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 136c4e9..791685f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -149,6 +149,9 @@ weeks months years + + Enter a number from 1 to 999 Ends Never On a date diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt new file mode 100644 index 0000000..213d31d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt @@ -0,0 +1,69 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import java.time.LocalDate +import java.time.ZoneId +import org.junit.jupiter.api.Test + +/** + * The read side of the UNTIL round-trip. `SimpleRecurrence.toRRule` deliberately + * writes the end of the chosen *local* day expressed in UTC (see its docs — the + * provider applies UNTIL coarsely), so displaying it means converting back into + * the device zone first. Reading the leading digits raw showed the wrong day for + * zones behind UTC. + */ +class RecurrenceTextTest { + + private val berlin = ZoneId.of("Europe/Berlin") + private val losAngeles = ZoneId.of("America/Los_Angeles") + private val utc = ZoneId.of("UTC") + + @Test + fun `UTC form converts back to the picked day west of UTC`() { + // toRRule("2026-12-31", America/Los_Angeles) → 23:59:59 local = 07:59:59Z + // on 1 Jan. Reading the digits raw would show 1 Jan 2027. + assertThat(untilLocalDate("20270101T075959Z", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `UTC form converts back to the picked day east of UTC`() { + // Berlin in December is UTC+1: 23:59:59 local = 22:59:59Z the same day. + assertThat(untilLocalDate("20261231T225959Z", berlin)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `UTC form is unchanged at UTC itself`() { + assertThat(untilLocalDate("20261231T235959Z", utc)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `summer offset is honoured, not a fixed one`() { + // Berlin in July is UTC+2, so the same local end-of-day lands at 21:59:59Z. + assertThat(untilLocalDate("20260801T215959Z", berlin)) + .isEqualTo(LocalDate.of(2026, 8, 1)) + } + + @Test + fun `date-only form is already local and passes through`() { + assertThat(untilLocalDate("20261231", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `floating date-time form uses its date as-is`() { + // No trailing Z, so it isn't an instant — no conversion may be applied. + assertThat(untilLocalDate("20261231T235959", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `garbage returns null rather than throwing`() { + assertThat(untilLocalDate("", berlin)).isNull() + assertThat(untilLocalDate("nonsense", berlin)).isNull() + assertThat(untilLocalDate("2026", berlin)).isNull() + assertThat(untilLocalDate("20261340", berlin)).isNull() + } +} -- 2.49.1 From 463811056ded88dd3cefbeb47cc670a33916024e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 15:20:37 +0200 Subject: [PATCH 4/5] refactor(ui): one selection check and one motion spec across the pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three inconsistencies the recurrence-picker review surfaced, all of the same kind: the redesigned picker had quietly diverged from the family. SelectedCheck existed as five copies app-side plus a private one in the kit, and they had drifted — the kit drew Icons.Rounded.Check, every app copy drew Icons.Default.Check. Delete all five against the kit's now public primitive (floret-kit!2), which settles the glyph on Rounded. The recurrence picker was the only full-screen picker whose selected row carried no check, relying on the tonal highlight alone. Add it to all six rows, matching OptionPicker, reminder, agenda range, timezone, calendar and Settings. Its two new AnimatedVisibility blocks were the only ones in the app using Compose's bare defaults rather than expandEnter()/collapseExit(). Those helpers honour rememberReduceMotion(), so the weekday and count cards were ignoring the system "remove animations" setting — an accessibility regression, not just a style drift. Bumps the floret-kit pointer; needs floret-kit!2 merged first. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/common/CalendarPickerGroups.kt | 10 +---- .../calendula/ui/common/Picker.kt | 10 +---- .../calendula/ui/common/TimeZonePicker.kt | 10 +---- .../calendula/ui/edit/EventEditScreen.kt | 43 ++++++++++++++++++- .../calendula/ui/settings/SettingsScreen.kt | 18 ++------ floret-kit | 2 +- 6 files changed, 49 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt index d46ad50..8a25102 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material3.Icon @@ -33,6 +32,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck /** * The app's single "which calendar" selection list, shared by the event editor @@ -99,13 +99,7 @@ private fun CalendarPickerGroup( selected = isSelected, leading = { CalendarColorChip(calendar.color) }, trailing = if (isSelected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } + { SelectedCheck() } } else { null }, 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 1aefd87..c038dee 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 @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -26,6 +25,7 @@ import de.jeanlucmakiola.floret.components.CustomAmountEditor import de.jeanlucmakiola.floret.components.FullScreenPicker 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 @@ -207,14 +207,6 @@ private fun PickerDescription(text: String) { ) } -@Composable -private fun SelectedCheck() { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) -} /** * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned 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 fc5d472..cf20da7 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 @@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Search @@ -41,6 +40,7 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.locale.currentLocale @@ -223,14 +223,6 @@ private fun SectionHeader(text: String) { ) } -@Composable -private fun SelectedCheck() { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) -} @Composable private fun ZoneRow( 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 13dd8ed..019c830 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 @@ -145,6 +145,7 @@ import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS @@ -1379,6 +1380,11 @@ private fun RecurrencePickerDialog( title = stringResource(R.string.event_edit_recurrence_none), position = positionOf(0, rowCount), selected = current == null, + trailing = if (current == null) { + { SelectedCheck() } + } else { + null + }, onClick = { onSelect(null) }, ) RecurrenceFreq.entries.forEachIndexed { index, entry -> @@ -1386,6 +1392,11 @@ private fun RecurrencePickerDialog( title = stringResource(recurrencePresetLabel(entry)), position = positionOf(index + 1, rowCount), selected = isPlainPreset && parsed?.freq == entry, + trailing = if (isPlainPreset && parsed?.freq == entry) { + { SelectedCheck() } + } else { + null + }, onClick = { onSelect(SimpleRecurrence(entry).toRRule()) }, ) } @@ -1393,6 +1404,11 @@ private fun RecurrencePickerDialog( title = stringResource(R.string.event_edit_recurrence_custom), position = positionOf(rowCount - 1, rowCount), selected = current != null && !isPlainPreset, + trailing = if (current != null && !isPlainPreset) { + { SelectedCheck() } + } else { + null + }, onClick = { customMode = true }, ) } else { @@ -1477,7 +1493,11 @@ private fun RecurrencePickerDialog( // Weekday picks — only meaningful on a weekly rule. The 16dp gap // sits inside the animated block so it grows/shrinks with the card. - AnimatedVisibility(visible = freq == RecurrenceFreq.Weekly) { + AnimatedVisibility( + visible = freq == RecurrenceFreq.Weekly, + enter = expandEnter(), + exit = collapseExit(), + ) { GroupedSurface( position = Position.Alone, modifier = Modifier @@ -1507,6 +1527,11 @@ private fun RecurrencePickerDialog( title = stringResource(R.string.event_edit_recurrence_end_never), position = Position.Top, selected = endMode == RecurrenceEndMode.Never, + trailing = if (endMode == RecurrenceEndMode.Never) { + { SelectedCheck() } + } else { + null + }, onClick = { endMode = RecurrenceEndMode.Never }, ) GroupedRow( @@ -1514,6 +1539,11 @@ private fun RecurrencePickerDialog( summary = untilSummary, position = Position.Middle, selected = endMode == RecurrenceEndMode.Until, + trailing = if (endMode == RecurrenceEndMode.Until) { + { SelectedCheck() } + } else { + null + }, onClick = { endMode = RecurrenceEndMode.Until showUntilPicker = true @@ -1527,12 +1557,21 @@ private fun RecurrencePickerDialog( Position.Bottom }, selected = endMode == RecurrenceEndMode.Count, + trailing = if (endMode == RecurrenceEndMode.Count) { + { SelectedCheck() } + } else { + null + }, onClick = { endMode = RecurrenceEndMode.Count }, ) // Animated for the same reason the weekday card is: this is the // picker's other collapsible block, and a bare `if` here made // the ends group snap while the card above it glided. - AnimatedVisibility(visible = endMode == RecurrenceEndMode.Count) { + AnimatedVisibility( + visible = endMode == RecurrenceEndMode.Count, + enter = expandEnter(), + exit = collapseExit(), + ) { GroupedSurface( position = Position.Bottom, modifier = Modifier.padding(horizontal = 16.dp), 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 7339a75..712635b 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 @@ -43,7 +43,6 @@ import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Cake import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Dashboard -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.filled.ExpandLess @@ -131,6 +130,7 @@ import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert +import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker @@ -1093,13 +1093,7 @@ private fun NotificationsScreen( }, position = Position.Top, trailing = if (batteryExempt) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } + { SelectedCheck() } } else { null }, @@ -1845,13 +1839,7 @@ private fun FontOptionRow( } }, trailing = if (selected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } + { SelectedCheck() } } else { null }, diff --git a/floret-kit b/floret-kit index df8bdba..b495471 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit df8bdbaf73380a354b7d2ee28e08875e155c89cd +Subproject commit b4954713ff2a476b8b4e4c1f93d9267547a757e4 -- 2.49.1 From 6a454e0b33ce235e4bd61210591fb47cfae22d38 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Jul 2026 15:28:05 +0200 Subject: [PATCH 5/5] chore: point floret-kit at merged main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit floret-kit!2 (public SelectedCheck) is merged, so the submodule no longer needs to track the feature branch. Same tree, so nothing rebuilds — this only stops Calendula pinning a branch that is now deletable. Co-Authored-By: Claude Opus 4.8 (1M context) --- floret-kit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/floret-kit b/floret-kit index b495471..75b90d1 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit b4954713ff2a476b8b4e4c1f93d9267547a757e4 +Subproject commit 75b90d1b3b7b1d6e050cc0ee961d4a8867abd60d -- 2.49.1