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 d19271e..af8c38b 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/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/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/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index fe06016..6400212 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 @@ -43,6 +42,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 import kotlinx.coroutines.Dispatchers @@ -244,14 +244,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 74ec60b..b064a35 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 @@ -98,7 +98,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 @@ -139,11 +138,14 @@ 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.SelectedCheck +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 @@ -162,7 +164,6 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.isoDayNumber import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaLocalDate -import kotlinx.datetime.toKotlinDayOfWeek import kotlinx.datetime.toInstant import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime @@ -171,7 +172,6 @@ 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 @@ -503,6 +503,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 @@ -1141,6 +1142,7 @@ private fun EventEditContent( RecurrencePickerDialog( current = form.rrule, startDay = form.start.date.dayOfWeek, + firstDayOfWeek = firstDayOfWeek, onSelect = { rrule -> viewModel.setRecurrence(rrule) showRecurrencePicker = false @@ -1312,6 +1314,7 @@ private enum class RecurrenceEndMode { Never, Until, Count } private fun RecurrencePickerDialog( current: String?, startDay: DayOfWeek, + firstDayOfWeek: DayOfWeek, onSelect: (String?) -> Unit, onDismiss: () -> Unit, ) { @@ -1346,8 +1349,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) } @@ -1383,6 +1399,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 -> @@ -1390,6 +1411,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()) }, ) } @@ -1397,88 +1423,195 @@ 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 { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(horizontal = 24.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, - ) - } + // 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. 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 = stringResource(R.string.event_edit_recurrence_ends), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + 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), ) - } - 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. + GroupedSurface( + position = Position.Alone, + modifier = Modifier + .padding(top = 16.dp) + .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, + ), + // 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, + ) + }, + ) + } + } + } + } + + // 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, + enter = expandEnter(), + exit = collapseExit(), + ) { + GroupedSurface( + position = Position.Alone, + modifier = Modifier + .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), + ) + } + } + + // Ends: a connected never / on-a-date / after-N group, the count + // field folding into the bottom of the run when chosen. + Column(modifier = Modifier.padding(top = 16.dp)) { 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, + trailing = if (endMode == RecurrenceEndMode.Never) { + { SelectedCheck() } + } else { + null + }, + onClick = { endMode = RecurrenceEndMode.Never }, + ) + GroupedRow( + title = stringResource(R.string.event_edit_recurrence_end_until), + summary = untilSummary, + position = Position.Middle, + selected = endMode == RecurrenceEndMode.Until, + trailing = if (endMode == RecurrenceEndMode.Until) { + { SelectedCheck() } + } else { + null + }, + 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, + 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, + enter = expandEnter(), + exit = collapseExit(), + ) { + GroupedSurface( + position = Position.Bottom, + modifier = Modifier.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, + ) + } + } + } } } } @@ -1493,7 +1626,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 + }, ) } } @@ -1508,14 +1647,15 @@ 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, - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), ) { days.forEach { day -> val isSelected = day in selected @@ -1568,18 +1708,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 b339f39..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,6 +8,7 @@ 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.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability @@ -40,6 +41,7 @@ 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 @@ -267,6 +269,13 @@ 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.firstDayOfWeek(viewModelScope) + /** * 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 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/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 3f4788c..f58c743 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 @@ -44,8 +44,8 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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.Dashboard import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.filled.ExpandLess @@ -135,6 +135,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 @@ -1149,13 +1150,7 @@ private fun NotificationsScreen( }, position = Position.Top, trailing = if (batteryExempt) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } + { SelectedCheck() } } else { null }, @@ -1993,13 +1988,7 @@ private fun FontOptionRow( } }, trailing = if (selected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } + { SelectedCheck() } } else { null }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfb853d..c7ff4e1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -152,6 +152,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() + } +} diff --git a/floret-kit b/floret-kit index df8bdba..75b90d1 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit df8bdbaf73380a354b7d2ee28e08875e155c89cd +Subproject commit 75b90d1b3b7b1d6e050cc0ee961d4a8867abd60d