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) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:56:55 +02:00
parent 426ddf27ee
commit ccd433fee1

View File

@@ -1382,8 +1382,43 @@ private fun RecurrencePickerDialog(
) )
} else { } else {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(),
modifier = Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// A live, human-readable read-out of the rule being built, so the
// effect of the controls below is never a guess.
Text(
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),
)
// 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),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
@@ -1396,31 +1431,52 @@ 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 },
onPick = { freq = RecurrenceFreq.entries[it] }, shape = SegmentedButtonDefaults.itemShape(
index, RecurrenceFreq.entries.size,
),
label = { Text(stringResource(recurrenceUnitLabel(entry))) },
) )
} }
if (freq == RecurrenceFreq.Weekly) { }
}
}
// 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( 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,
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(
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,
onClick = { endMode = RecurrenceEndMode.Never }, onClick = { endMode = RecurrenceEndMode.Never },
) )
@@ -1432,7 +1488,7 @@ private fun RecurrencePickerDialog(
.withLocale(locale).format(it.toJavaLocalDate()) .withLocale(locale).format(it.toJavaLocalDate())
} }
}, },
position = positionOf(1, 3), position = Position.Middle,
selected = endMode == RecurrenceEndMode.Until, selected = endMode == RecurrenceEndMode.Until,
onClick = { onClick = {
endMode = RecurrenceEndMode.Until endMode = RecurrenceEndMode.Until
@@ -1441,14 +1497,25 @@ 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,
onClick = { endMode = RecurrenceEndMode.Count }, onClick = { endMode = RecurrenceEndMode.Count },
) )
if (endMode == RecurrenceEndMode.Count) { if (endMode == RecurrenceEndMode.Count) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = groupedShape(Position.Bottom),
modifier = Modifier
.fillMaxWidth()
.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,
@@ -1464,6 +1531,9 @@ private fun RecurrencePickerDialog(
} }
} }
} }
}
}
}
if (showUntilPicker) { if (showUntilPicker) {
CalendarDatePickerDialog( CalendarDatePickerDialog(
@@ -1489,6 +1559,7 @@ private fun WeekdayToggleRow(
selected: Set<DayOfWeek>, selected: Set<DayOfWeek>,
onToggle: (DayOfWeek) -> Unit, onToggle: (DayOfWeek) -> Unit,
locale: Locale, locale: Locale,
modifier: Modifier = Modifier,
) { ) {
val days = remember(locale) { val days = remember(locale) {
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek() val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek()
@@ -1496,7 +1567,7 @@ private fun WeekdayToggleRow(
} }
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