fix(edit): recurrence picker review fixes + picker unification (#42) #89

Merged
makiolaj merged 6 commits from feat/recurrence-exdate into release/v2.16.0 2026-07-20 13:29:16 +00:00
13 changed files with 380 additions and 162 deletions

View File

@@ -23,8 +23,12 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import java.time.ZoneId import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import java.time.temporal.WeekFields import java.time.temporal.WeekFields
import java.util.Locale import java.util.Locale
@@ -82,6 +86,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value) 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<DayOfWeek> =
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 * Display settings (M4) persisted app-side: theme override, Material You
* dynamic colour, and week start. Language is handled separately through * dynamic colour, and week start. Language is handled separately through

View File

@@ -7,7 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs 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.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import java.util.Locale
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.plus import kotlinx.datetime.plus
@@ -51,8 +49,7 @@ class AgendaViewModel @Inject constructor(
) { range, showBar -> AgendaSettings(range, showBar) } ) { range, showBar -> AgendaSettings(range, showBar) }
// First day of the week, for the calendar-aligned "this week" range. // First day of the week, for the calendar-aligned "this week" range.
private val weekStartDay = settingsPrefs.weekStart private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
.map { it.resolveFirstDay(Locale.getDefault()) }
/** /**
* How to treat events that already ended today (show / dim / hide). A display * How to treat events that already ended today (show / dim / hide). A display

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.Cloud
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -33,6 +32,7 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position 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 * The app's single "which calendar" selection list, shared by the event editor
@@ -99,13 +99,7 @@ private fun CalendarPickerGroup(
selected = isSelected, selected = isSelected,
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) { trailing = if (isSelected) {
{ { SelectedCheck() }
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else { } else {
null null
}, },

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter 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 * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned

View File

@@ -9,6 +9,10 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import java.time.DayOfWeek 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.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle 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. */ /** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
private fun parseUntilDate(raw: String, locale: Locale): String? { private fun parseUntilDate(raw: String, locale: Locale): String? {
val digits = raw.takeWhile { it.isDigit() } val date = untilLocalDate(raw) ?: return null
if (digits.length < 8) return null return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
return try { }
val date = java.time.LocalDate.of(
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(0, 4).toInt(),
digits.substring(4, 6).toInt(), digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(), digits.substring(6, 8).toInt(),
) )
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date) }.getOrNull()
} catch (e: Exception) {
null
}
} }

View File

@@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.Close
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Search 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.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.Dispatchers 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 @Composable
private fun ZoneRow( private fun ZoneRow(

View File

@@ -98,7 +98,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel 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.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position 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.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
@@ -162,7 +164,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.isoDayNumber import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.toJavaDayOfWeek import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toKotlinDayOfWeek
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toJavaLocalTime
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -171,7 +172,6 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.time.temporal.WeekFields
import java.util.Locale import java.util.Locale
import kotlin.time.Clock import kotlin.time.Clock
@@ -503,6 +503,7 @@ private fun EventEditContent(
) { ) {
val form = state.form val form = state.form
val locale = currentLocale() val locale = currentLocale()
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
// The title, date and recurrence are managed by the special-dates sync, so // The title, date and recurrence are managed by the special-dates sync, so
// they're locked here; everything else (reminders, location, notes) is the // they're locked here; everything else (reminders, location, notes) is the
@@ -1141,6 +1142,7 @@ private fun EventEditContent(
RecurrencePickerDialog( RecurrencePickerDialog(
current = form.rrule, current = form.rrule,
startDay = form.start.date.dayOfWeek, startDay = form.start.date.dayOfWeek,
firstDayOfWeek = firstDayOfWeek,
onSelect = { rrule -> onSelect = { rrule ->
viewModel.setRecurrence(rrule) viewModel.setRecurrence(rrule)
showRecurrencePicker = false showRecurrencePicker = false
@@ -1312,6 +1314,7 @@ private enum class RecurrenceEndMode { Never, Until, Count }
private fun RecurrencePickerDialog( private fun RecurrencePickerDialog(
current: String?, current: String?,
startDay: DayOfWeek, startDay: DayOfWeek,
firstDayOfWeek: DayOfWeek,
onSelect: (String?) -> Unit, onSelect: (String?) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
@@ -1346,8 +1349,21 @@ private fun RecurrencePickerDialog(
val locale = currentLocale() val locale = currentLocale()
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 } // remember() must not sit behind the ?. — a slot that appears and disappears
val count = countText.toIntOrNull()?.takeIf { it in 1..999 } // 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) { val customEnd: RecurrenceEnd? = when (endMode) {
RecurrenceEndMode.Never -> RecurrenceEnd.Never RecurrenceEndMode.Never -> RecurrenceEnd.Never
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) } RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
@@ -1383,6 +1399,11 @@ private fun RecurrencePickerDialog(
title = stringResource(R.string.event_edit_recurrence_none), title = stringResource(R.string.event_edit_recurrence_none),
position = positionOf(0, rowCount), position = positionOf(0, rowCount),
selected = current == null, selected = current == null,
trailing = if (current == null) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(null) }, onClick = { onSelect(null) },
) )
RecurrenceFreq.entries.forEachIndexed { index, entry -> RecurrenceFreq.entries.forEachIndexed { index, entry ->
@@ -1390,6 +1411,11 @@ private fun RecurrencePickerDialog(
title = stringResource(recurrencePresetLabel(entry)), title = stringResource(recurrencePresetLabel(entry)),
position = positionOf(index + 1, rowCount), position = positionOf(index + 1, rowCount),
selected = isPlainPreset && parsed?.freq == entry, selected = isPlainPreset && parsed?.freq == entry,
trailing = if (isPlainPreset && parsed?.freq == entry) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) }, onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
) )
} }
@@ -1397,12 +1423,54 @@ private fun RecurrencePickerDialog(
title = stringResource(R.string.event_edit_recurrence_custom), title = stringResource(R.string.event_edit_recurrence_custom),
position = positionOf(rowCount - 1, rowCount), position = positionOf(rowCount - 1, rowCount),
selected = current != null && !isPlainPreset, selected = current != null && !isPlainPreset,
trailing = if (current != null && !isPlainPreset) {
{ SelectedCheck() }
} else {
null
},
onClick = { customMode = true }, onClick = { customMode = true },
) )
} else { } else {
// 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 = 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),
)
// 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),
) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
@@ -1415,44 +1483,86 @@ private fun RecurrencePickerDialog(
onValueChange = { intervalText = it }, onValueChange = { intervalText = it },
placeholder = "1", placeholder = "1",
) )
Spacer(Modifier.width(12.dp)) }
DialogUnitDropdown( SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
label = stringResource(recurrenceUnitLabel(freq)), RecurrenceFreq.entries.forEachIndexed { index, entry ->
entries = RecurrenceFreq.entries.map { SegmentedButton(
stringResource(recurrenceUnitLabel(it)) 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,
)
}, },
onPick = { freq = RecurrenceFreq.entries[it] },
) )
} }
if (freq == RecurrenceFreq.Weekly) { }
}
}
// 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( WeekdayToggleRow(
selected = daysMask.toDaySet(), selected = daysMask.toDaySet(),
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() }, onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
locale = locale, 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(
text = stringResource(R.string.event_edit_recurrence_ends), text = stringResource(R.string.event_edit_recurrence_ends),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
) )
}
GroupedRow( GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_never), title = stringResource(R.string.event_edit_recurrence_end_never),
position = positionOf(0, 3), position = Position.Top,
selected = endMode == RecurrenceEndMode.Never, selected = endMode == RecurrenceEndMode.Never,
trailing = if (endMode == RecurrenceEndMode.Never) {
{ SelectedCheck() }
} else {
null
},
onClick = { endMode = RecurrenceEndMode.Never }, onClick = { endMode = RecurrenceEndMode.Never },
) )
GroupedRow( GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_until), title = stringResource(R.string.event_edit_recurrence_end_until),
summary = untilDate?.let { summary = untilSummary,
remember(it, locale) { position = Position.Middle,
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale).format(it.toJavaLocalDate())
}
},
position = positionOf(1, 3),
selected = endMode == RecurrenceEndMode.Until, selected = endMode == RecurrenceEndMode.Until,
trailing = if (endMode == RecurrenceEndMode.Until) {
{ SelectedCheck() }
} else {
null
},
onClick = { onClick = {
endMode = RecurrenceEndMode.Until endMode = RecurrenceEndMode.Until
showUntilPicker = true showUntilPicker = true
@@ -1460,14 +1570,34 @@ private fun RecurrencePickerDialog(
) )
GroupedRow( GroupedRow(
title = stringResource(R.string.event_edit_recurrence_end_count), title = stringResource(R.string.event_edit_recurrence_end_count),
position = positionOf(2, 3), position = if (endMode == RecurrenceEndMode.Count) {
Position.Middle
} else {
Position.Bottom
},
selected = endMode == RecurrenceEndMode.Count, selected = endMode == RecurrenceEndMode.Count,
trailing = if (endMode == RecurrenceEndMode.Count) {
{ SelectedCheck() }
} else {
null
},
onClick = { endMode = RecurrenceEndMode.Count }, onClick = { endMode = RecurrenceEndMode.Count },
) )
if (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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), modifier = Modifier.padding(16.dp),
) { ) {
DialogAmountField( DialogAmountField(
value = countText, value = countText,
@@ -1483,6 +1613,9 @@ private fun RecurrencePickerDialog(
} }
} }
} }
}
}
}
if (showUntilPicker) { if (showUntilPicker) {
CalendarDatePickerDialog( CalendarDatePickerDialog(
@@ -1493,7 +1626,13 @@ private fun RecurrencePickerDialog(
untilIso = it.toString() untilIso = it.toString()
showUntilPicker = false 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<DayOfWeek>, selected: Set<DayOfWeek>,
onToggle: (DayOfWeek) -> Unit, onToggle: (DayOfWeek) -> Unit,
locale: Locale, locale: Locale,
firstDay: DayOfWeek,
modifier: Modifier = Modifier,
) { ) {
val days = remember(locale) { val days = remember(firstDay) {
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek() (0 until 7).map { DayOfWeek(((firstDay.isoDayNumber - 1 + it) % 7) + 1) }
(0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) }
} }
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
days.forEach { day -> days.forEach { day ->
val isSelected = day in selected val isSelected = day in selected
@@ -1568,18 +1708,14 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years 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 = * Corner shape for a card at [position] in a grouped run. Delegates to the
when (position) { * family primitive with the same radii [GroupedSurface] draws at rest — a local
Position.Alone -> RoundedCornerShape(full) * copy had drifted to 20dp, so a card sharing a run with GroupedRows had
Position.Top -> RoundedCornerShape( * visibly tighter outer corners than the rows above it.
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small, */
) private fun groupedShape(position: Position): Shape =
Position.Middle -> RoundedCornerShape(small) floretGroupedShape(position, full = 22.dp, small = 6.dp)
Position.Bottom -> RoundedCornerShape(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
/** The 2dp gap that visually separates grouped cards (none after the last). */ /** The 2dp gap that visually separates grouped cards (none after the last). */
private fun groupedGap(position: Position): Modifier = when (position) { private fun groupedGap(position: Position): Modifier = when (position) {

View File

@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs 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.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
@@ -40,6 +41,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
@@ -267,6 +269,13 @@ class EventEditViewModel @Inject constructor(
initialValue = null, 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<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
/** /**
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes * 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 * from midnight) anchors the start when the form is opened by tapping a slot

View File

@@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs 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.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -34,7 +33,6 @@ import kotlinx.datetime.minus
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -48,16 +46,9 @@ class MonthViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val locale: Locale = Locale.getDefault()
/** First day of the week, from the Settings preference (AUTO → locale). */ /** First day of the week, from the Settings preference (AUTO → locale). */
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
.map { it.resolveFirstDay(locale) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayOfWeek.MONDAY,
)
/** Whether to fade events that have already finished (display concern only). */ /** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents

View File

@@ -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.BugReport
import androidx.compose.material.icons.filled.Cake import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.CalendarMonth 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.Check
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.ExpandLess 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.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
@@ -1149,13 +1150,7 @@ private fun NotificationsScreen(
}, },
position = Position.Top, position = Position.Top,
trailing = if (batteryExempt) { trailing = if (batteryExempt) {
{ { SelectedCheck() }
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else { } else {
null null
}, },
@@ -1993,13 +1988,7 @@ private fun FontOptionRow(
} }
}, },
trailing = if (selected) { trailing = if (selected) {
{ { SelectedCheck() }
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else { } else {
null null
}, },

View File

@@ -152,6 +152,9 @@
<string name="recurrence_unit_weeks">weeks</string> <string name="recurrence_unit_weeks">weeks</string>
<string name="recurrence_unit_months">months</string> <string name="recurrence_unit_months">months</string>
<string name="recurrence_unit_years">years</string> <string name="recurrence_unit_years">years</string>
<!-- Shown in place of the live rule read-out when an amount field holds a
value outside 1999 (i.e. 0), so the greyed-out OK button has a reason. -->
<string name="event_edit_recurrence_incomplete">Enter a number from 1 to 999</string>
<string name="event_edit_recurrence_ends">Ends</string> <string name="event_edit_recurrence_ends">Ends</string>
<string name="event_edit_recurrence_end_never">Never</string> <string name="event_edit_recurrence_end_never">Never</string>
<string name="event_edit_recurrence_end_until">On a date</string> <string name="event_edit_recurrence_end_until">On a date</string>

View File

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