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,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<DayOfWeek>,
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