Compare commits
2 Commits
release/v2
...
feat/recur
| Author | SHA1 | Date | |
|---|---|---|---|
| 722fc87259 | |||
| ccd433fee1 |
@@ -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,85 +1382,159 @@ private fun RecurrencePickerDialog(
|
||||
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.
|
||||
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(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,
|
||||
),
|
||||
label = { Text(stringResource(recurrenceUnitLabel(entry))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
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,14 +1564,15 @@ private fun WeekdayToggleRow(
|
||||
selected: Set<DayOfWeek>,
|
||||
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
|
||||
|
||||
@@ -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<DayOfWeek> = 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
|
||||
|
||||
Reference in New Issue
Block a user