refactor(settings): dedupe override decoding + on-convention template input

- choiceFor moves next to CalendarReminderOverride in SettingsPrefs and
  replaces the hand-rolled null/empty/else decoder in SettingsViewModel,
  so the picker and the special-dates rows can't drift apart.
- The special-dates template dialog used the codebase's only
  OutlinedTextField; it now follows the documented dialog convention
  (InlineTextField over a tonal surface, see DialogControls).
- reminderChoiceLabel drops the manual loop (and its wrong comment —
  map is inline, so composable calls are fine in its lambda).
- The blank-template fallback is shared with the sync engine via
  resolveTitleTemplate instead of being copy-pasted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:40:44 +02:00
parent b8a420bdab
commit 67a07a9346
3 changed files with 37 additions and 31 deletions

View File

@@ -611,6 +611,18 @@ sealed interface CalendarReminderOverride {
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
}
/**
* The stored override for [calendarId] in an override map, as a
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
* empty → [CalendarReminderOverride.None]) — the inverse of how
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
*/
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
}
/**
* The lead times to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for

View File

@@ -86,8 +86,10 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
@@ -104,6 +106,7 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
@@ -1090,7 +1093,7 @@ private fun SpecialDatesScreen(
}
}
val requestOrEnable: () -> Unit = {
if (hasContactsPermission(context)) {
if (context.hasContactsPermission()) {
viewModel.setSpecialDatesEnabled(true)
} else {
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
@@ -1290,12 +1293,21 @@ private fun SpecialDatesTemplateDialog(
title = { Text(stringResource(R.string.settings_special_dates_template)) },
text = {
Column {
androidx.compose.material3.OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// The app's borderless input over a tonal surface (the dialog
// convention — see DialogControls), not Material's outlined field.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = text,
onValueChange = { text = it },
placeholder = stringResource(R.string.settings_special_dates_template),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.settings_special_dates_template_hint),
@@ -1324,10 +1336,6 @@ private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
}
private fun hasContactsPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
@Composable
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
if (lastRun <= 0L) {
@@ -1426,22 +1434,11 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
return DateFormat.getTimeFormat(context).format(time)
}
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
}
/** Label for a global-default choice: empty → "None", else the lead times joined. */
@Composable
private fun reminderChoiceLabel(minutes: List<Int>): String {
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
// than a (non-composable) joinToString transform.
val parts = ArrayList<String>(minutes.size)
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
return parts.joinToString(", ")
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
}
/** Row summary for a calendar: its override, or the inherited global default. */

View File

@@ -13,8 +13,10 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
@@ -195,8 +197,7 @@ class SettingsViewModel @Inject constructor(
// A blank stored template resolves to the localized default so the
// editor always shows something sensible.
titleTemplates = SpecialDateType.entries.associateWith { type ->
templates[type]?.takeIf { it.isNotBlank() }
?: specialDatesSpec.defaultTitleTemplate(type)
specialDatesSpec.resolveTitleTemplate(type, templates)
},
showYear = showYear,
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
@@ -207,13 +208,9 @@ class SettingsViewModel @Inject constructor(
prefs.perCalendarAllDayReminderOverride,
) { base, calendars, allDayOverrides ->
base.copy(
// An override is always seeded on creation; present-empty = None.
reminderChoices = calendars.mapValues { (_, calendarId) ->
// An override is always seeded on creation; present-empty = None.
when (val minutes = allDayOverrides[calendarId]) {
null -> CalendarReminderOverride.Inherit
emptyList<Int>() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(minutes)
}
allDayOverrides.choiceFor(calendarId)
},
)
}.stateIn(