diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 361bd54..b314b46 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -162,6 +162,7 @@ dependencies { implementation("de.jeanlucmakiola.floret:core-time") implementation("de.jeanlucmakiola.floret:core-locale") implementation("de.jeanlucmakiola.floret:core-crash") + implementation("de.jeanlucmakiola.floret:core-reminders") implementation(libs.kotlinx.coroutines.core) debugImplementation(libs.androidx.ui.tooling) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 0fec684..e479411 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -12,6 +12,9 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.floret.reminders.ReminderOverride +import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec +import de.jeanlucmakiola.floret.reminders.applyReminderOverride import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -296,14 +299,14 @@ class SettingsPrefs @Inject constructor( * [defaultAllDayReminderMinutes].) */ val perCalendarReminderOverride: Flow> = store.data.map { prefs -> - parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]) + reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]) } - suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) { + suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) { store.edit { prefs -> - val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap() - current.applyOverride(calendarId, override) - prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current) + val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap() + current.applyReminderOverride(calendarId, override) + prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current) } } @@ -313,18 +316,18 @@ class SettingsPrefs @Inject constructor( * inherit the global all-day default; present null = no reminder). */ val perCalendarAllDayReminderOverride: Flow> = store.data.map { prefs -> - parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]) + reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]) } suspend fun setCalendarAllDayReminderOverride( calendarId: Long, - override: CalendarReminderOverride, + override: ReminderOverride, ) { store.edit { prefs -> val current = - parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap() - current.applyOverride(calendarId, override) - prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current) + reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap() + current.applyReminderOverride(calendarId, override) + prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current) } } @@ -434,16 +437,6 @@ data class BackupStatus( val consecutiveFailures: Int, ) -/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */ -sealed interface CalendarReminderOverride { - /** No override — the calendar uses the global default. */ - data object Inherit : CalendarReminderOverride - /** Explicit "no reminder" for this calendar, regardless of the global default. */ - data object None : CalendarReminderOverride - /** A specific lead time in minutes before the event start. */ - data class Minutes(val minutes: Int) : CalendarReminderOverride -} - /** * The lead time to prefill on a new event: the matching per-calendar override * if [calendarId] has one for this event kind, otherwise the global default for @@ -468,18 +461,6 @@ fun resolveDefaultReminder( } } -/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */ -private fun MutableMap.applyOverride( - calendarId: Long, - override: CalendarReminderOverride, -) { - when (override) { - CalendarReminderOverride.Inherit -> remove(calendarId) - CalendarReminderOverride.None -> put(calendarId, null) - is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes) - } -} - /** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */ private const val WEEK_START_AUTO = "AUTO" @@ -500,26 +481,18 @@ private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) { } private const val NONE = "none" -private const val ENTRY_SEP = ";" -private const val KEY_VALUE_SEP = "=" private fun String?.toReminderMinutes(): Int? = when (this) { null, "", NONE -> null else -> toIntOrNull() } -private fun parseReminderOverrides(stored: String?): Map { - if (stored.isNullOrBlank()) return emptyMap() - return stored.split(ENTRY_SEP).mapNotNull { entry -> - val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null - val id = parts[0].toLongOrNull() ?: return@mapNotNull null - val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null - id to value - }.toMap() -} - -private fun serializeReminderOverrides(map: Map): String = - map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" } +/** + * Calendula's dialect of the shared per-target reminder-override codec: entries + * `id=minutes` joined by `;`, with `none` for an explicit no-reminder. These + * separators are the on-disk format and must not change without a migration. + */ +private val reminderOverrideCodec = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", noneToken = NONE) private inline fun > String?.toEnum(default: E): E = this?.let { stored -> enumValues().firstOrNull { it.name == stored } } ?: default diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index ce6abae..ef6c199 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -38,8 +38,10 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.floret.reminders.ReminderOverride +import de.jeanlucmakiola.floret.reminders.ReminderUnit +import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes /** * Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that @@ -114,29 +116,29 @@ fun OptionPicker( * Reminder-default picker, full-screen: the grouped list (with an optional "Use * default reminder" row and a "None" row), the [presets] as lead-time rows, and * a "Custom" row that expands an inline number field plus a segmented unit - * selector. Returns the choice as a [CalendarReminderOverride]. + * selector. Returns the choice as a [ReminderOverride]. */ @Composable fun ReminderDefaultPicker( title: String, presets: List, - selected: CalendarReminderOverride, + selected: ReminderOverride, allowInherit: Boolean, - onSelect: (CalendarReminderOverride) -> Unit, + onSelect: (ReminderOverride) -> Unit, onDismiss: () -> Unit, ) { - val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes + val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes val customSelected = selectedMinutes != null && selectedMinutes !in presets - val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected }) + val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected }) var customExpanded by rememberSaveable { mutableStateOf(false) } - var amountText by rememberSaveable { mutableStateOf(seed.first) } - var unit by rememberSaveable { mutableStateOf(seed.second) } + var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") } + var unit by rememberSaveable { mutableStateOf(seed.unit) } val options = buildList { - if (allowInherit) add(CalendarReminderOverride.Inherit) - add(CalendarReminderOverride.None) - presets.forEach { add(CalendarReminderOverride.Minutes(it)) } + if (allowInherit) add(ReminderOverride.Inherit) + add(ReminderOverride.None) + presets.forEach { add(ReminderOverride.Minutes(it)) } } val rowCount = options.size + 1 // + the custom row @@ -189,7 +191,7 @@ fun ReminderDefaultPicker( unit = unit, onUnitChange = { unit = it }, onConfirm = { minutes -> - onSelect(CalendarReminderOverride.Minutes(minutes)) + onSelect(ReminderOverride.Minutes(minutes)) onDismiss() }, ) @@ -429,17 +431,8 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) { } @Composable -private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) { - CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) - CalendarReminderOverride.None -> stringResource(R.string.reminder_none) - is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes) -} - -/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */ -private fun decomposeReminder(minutes: Int?): Pair = when { - minutes == null -> "" to ReminderUnit.Minutes - minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks - minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days - minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours - else -> minutes.toString() to ReminderUnit.Minutes +private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) { + ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) + ReminderOverride.None -> stringResource(R.string.reminder_none) + is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt index 2027592..74da165 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt @@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.floret.reminders.ReminderUnit /** Common reminder lead times offered as quick picks in the form and settings. */ val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440) -/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */ -enum class ReminderUnit(val minutesFactor: Int) { - Minutes(1), - Hours(60), - Days(1_440), - Weeks(10_080), -} - @StringRes fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) { ReminderUnit.Minutes -> R.string.reminder_unit_minutes diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 22d5ac6..6eed2bd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -130,8 +130,8 @@ import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS -import de.jeanlucmakiola.calendula.ui.common.ReminderUnit import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert +import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 45537b3..c0953c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -84,7 +84,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride +import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref @@ -973,12 +973,12 @@ private fun NotificationsScreen( private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean) /** A global default (null = none) as a picker choice for selection highlighting. */ -private fun Int?.toReminderChoice(): CalendarReminderOverride = - if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this) +private fun Int?.toReminderChoice(): ReminderOverride = + if (this == null) ReminderOverride.None else ReminderOverride.Minutes(this) /** A picked choice as global-default minutes (Inherit isn't offered for globals). */ -private fun CalendarReminderOverride.toMinutesOrNull(): Int? = - (this as? CalendarReminderOverride.Minutes)?.minutes +private fun ReminderOverride.toMinutesOrNull(): Int? = + (this as? ReminderOverride.Minutes)?.minutes /** * Whether Calendula is exempt from battery optimisation, re-read on every @@ -1053,10 +1053,10 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String { } /** The stored override for [calendarId], as a picker choice (absent → inherit). */ -private fun Map.choiceFor(calendarId: Long): CalendarReminderOverride = when { - !containsKey(calendarId) -> CalendarReminderOverride.Inherit - this[calendarId] == null -> CalendarReminderOverride.None - else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!) +private fun Map.choiceFor(calendarId: Long): ReminderOverride = when { + !containsKey(calendarId) -> ReminderOverride.Inherit + this[calendarId] == null -> ReminderOverride.None + else -> ReminderOverride.Minutes(this.getValue(calendarId)!!) } /** Label for a global-default choice: null → "None", else the lead time. */ @@ -1067,13 +1067,13 @@ private fun reminderChoiceLabel(minutes: Int?): String = /** Row summary for a calendar: its override, or the inherited global default. */ @Composable private fun calendarOverrideSummary( - choice: CalendarReminderOverride, + choice: ReminderOverride, globalDefault: Int?, ): String = when (choice) { - CalendarReminderOverride.Inherit -> + ReminderOverride.Inherit -> stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault)) - CalendarReminderOverride.None -> stringResource(R.string.reminder_none) - is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes) + ReminderOverride.None -> stringResource(R.string.reminder_none) + is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes) } // --------------------------------------------------------------------------- diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index b9516a6..8b7ec50 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -10,9 +10,9 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository -import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.ThemeMode +import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource @@ -229,11 +229,11 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setSnoozeMinutes(minutes) } } - fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) { + fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) { viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) } } - fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) { + fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) { viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 012b75d..d16a278 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.floret.reminders.ReminderOverride import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -206,14 +207,14 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(newDataStore(tempDir)) assertThat(prefs.perCalendarReminderOverride.first()).isEmpty() - prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) - prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None) + prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(15)) + prefs.setCalendarReminderOverride(9L, ReminderOverride.None) prefs.perCalendarReminderOverride.first().let { map -> assertThat(map).containsExactly(7L, 15, 9L, null) } // Inherit drops the override entirely (absent != null value). - prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit) + prefs.setCalendarReminderOverride(9L, ReminderOverride.Inherit) prefs.perCalendarReminderOverride.first().let { map -> assertThat(map).containsExactly(7L, 15) assertThat(map.containsKey(9L)).isFalse() @@ -233,12 +234,12 @@ class SettingsPrefsTest { @Test fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) - prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) - prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440)) + prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(15)) + prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Minutes(1_440)) assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440) // Clearing the all-day override leaves the timed one untouched. - prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit) + prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Inherit) assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty() assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) }