fix(edit): recurrence picker review fixes + picker unification (#42) #89
@@ -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<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
|
||||
* dynamic colour, and week start. Language is handled separately through
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<DayOfWeek> = 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
|
||||
|
||||
@@ -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<DayOfWeek> = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(locale) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -152,6 +152,9 @@
|
||||
<string name="recurrence_unit_weeks">weeks</string>
|
||||
<string name="recurrence_unit_months">months</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 1–999 (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_end_never">Never</string>
|
||||
<string name="event_edit_recurrence_end_until">On a date</string>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Submodule floret-kit updated: df8bdbaf73...75b90d1b3b
Reference in New Issue
Block a user