From 52857a7d3bf35c11c883b52851f1ab666a565be7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 16 Oct 2026 13:02:04 +0200 Subject: [PATCH] Move the prefs classes onto floret's PrefStore --- app/build.gradle.kts | 1 + .../jeanlucmakiola/calendula/MainActivity.kt | 18 +- .../calendula/data/prefs/CalendarPrefs.kt | 82 +- .../data/prefs/ReminderStatePrefs.kt | 17 +- .../calendula/data/prefs/SettingsPrefs.kt | 1536 +++++++---------- .../ui/onboarding/OnboardingViewModel.kt | 120 +- .../ui/settings/AppearanceSettings.kt | 2 +- .../calendula/ui/settings/SettingsUiState.kt | 2 +- .../ui/settings/SettingsViewModel.kt | 2 +- .../calendula/ui/theme/WindowTheme.kt | 2 +- .../calendula/data/prefs/CalendarPrefsTest.kt | 5 +- .../calendula/data/prefs/SettingsPrefsTest.kt | 52 +- 12 files changed, 755 insertions(+), 1084 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fc20dc1b..49b09e52 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -176,6 +176,7 @@ dependencies { implementation("de.jeanlucmakiola.floret:core-locale") implementation("de.jeanlucmakiola.floret:core-crash") implementation("de.jeanlucmakiola.floret:core-di") + implementation("de.jeanlucmakiola.floret:core-prefs") implementation("de.jeanlucmakiola.floret:core-reminders") implementation("de.jeanlucmakiola.floret:core-android") implementation("de.jeanlucmakiola.floret:identity") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 5f00251a..3d0e3994 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -29,7 +29,9 @@ import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.calendula.data.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.isDark +import de.jeanlucmakiola.floret.prefs.isPureBlack import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm @@ -137,11 +139,7 @@ class MainActivity : AppCompatActivity() { // backdrop and bar icons instead of the night qualifier's (#181). val cachedTheme = WindowThemeCache.read(this) cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) } - systemBarsDark = when (cachedTheme) { - null, ThemeMode.SYSTEM -> systemDark - ThemeMode.LIGHT -> false - ThemeMode.DARK, ThemeMode.BLACK -> true - } + systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark) applyEdgeToEdge() lifecycleScope.launch { settingsPrefs.themeMode.collect { mode -> @@ -180,11 +178,7 @@ class MainActivity : AppCompatActivity() { // and the Settings screen, so a theme change applies app-wide at once. val settingsViewModel: SettingsViewModel = hiltViewModel() val settings by settingsViewModel.state.collectAsStateWithLifecycle() - val darkTheme = when (settings.themeMode) { - ThemeMode.SYSTEM -> isSystemInDarkTheme() - ThemeMode.LIGHT -> false - ThemeMode.DARK, ThemeMode.BLACK -> true - } + val darkTheme = settings.themeMode.isDark(isSystemInDarkTheme()) // onCreate can only see the night resource qualifier, not the in-app // override — re-apply from the resolved theme so the bar icons follow // the app's light/dark choice. @@ -223,7 +217,7 @@ class MainActivity : AppCompatActivity() { CalendulaTheme( darkTheme = darkTheme, dynamicColor = settings.dynamicColor, - pureBlack = settings.themeMode == ThemeMode.BLACK, + pureBlack = settings.themeMode.isPureBlack, typography = typography, ) { Box(modifier = Modifier.fillMaxSize()) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt index b0bc1e7e..f75b69a4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt @@ -1,15 +1,13 @@ package de.jeanlucmakiola.calendula.data.prefs import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey +import de.jeanlucmakiola.floret.prefs.Pref +import de.jeanlucmakiola.floret.prefs.PrefStore +import de.jeanlucmakiola.floret.prefs.nullableBooleanPref +import de.jeanlucmakiola.floret.prefs.nullableLongPref +import de.jeanlucmakiola.floret.prefs.nullableStringPref import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -26,18 +24,13 @@ import javax.inject.Singleton */ @Singleton class CalendarPrefs @Inject constructor( - private val store: DataStore, + store: DataStore, ) { + private val prefs = PrefStore(store) - // Both id sets are deduped: the store is shared with SettingsPrefs, so any - // unrelated write would otherwise re-emit an identical set. - val hiddenCalendarIds: Flow> = store.data - .map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() } - .distinctUntilChanged() + val hiddenCalendarIds: Flow> = prefs.flow(HIDDEN_IDS) - suspend fun setHiddenCalendarIds(ids: Set) { - store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) } - } + suspend fun setHiddenCalendarIds(ids: Set) = prefs.set(HIDDEN_IDS, ids) /** * Switch-offs the provider does not know about yet, because writing @@ -48,24 +41,18 @@ class CalendarPrefs @Inject constructor( * second visibility model: `CalendarVisibilityReconciler` drains it entry by * entry as soon as the app may write, and nothing adds to it while it may. */ - val pendingDisabledCalendarIds: Flow> = store.data - .map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() } - .distinctUntilChanged() + val pendingDisabledCalendarIds: Flow> = prefs.flow(DISABLED_IDS) - suspend fun addPendingDisabledCalendarIds(ids: Collection) = - editPendingDisabled { it + ids } + suspend fun addPendingDisabledCalendarIds(ids: Collection) { + prefs.update(DISABLED_IDS) { it + ids } + } /** * Drop [ids] from the pending set, one at a time as the reconciler flushes * them, so a run that fails part-way never re-applies what already landed. */ - suspend fun removePendingDisabledCalendarIds(ids: Collection) = - editPendingDisabled { it - ids.toSet() } - - private suspend fun editPendingDisabled(transform: (Set) -> Set) { - store.edit { prefs -> - prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds())) - } + suspend fun removePendingDisabledCalendarIds(ids: Collection) { + prefs.update(DISABLED_IDS) { it - ids.toSet() } } /** @@ -73,39 +60,28 @@ class CalendarPrefs @Inject constructor( * owed. Null until the reconciler has evaluated it (which needs the calendar * permission), false once it has been shown or was never needed. */ - val visibilityNoticePending: Flow = store.data.map { prefs -> - prefs[VISIBILITY_NOTICE_KEY] - } + val visibilityNoticePending: Flow = prefs.flow(VISIBILITY_NOTICE) - suspend fun setVisibilityNoticePending(pending: Boolean) { - store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending } - } + suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, pending) /** * The calendar the user last created an event in; preselected in the * event form. Null until the first event is created. */ - val lastUsedCalendarId: Flow = store.data.map { prefs -> - prefs[LAST_USED_CALENDAR_KEY] - } + val lastUsedCalendarId: Flow = prefs.flow(LAST_USED_CALENDAR) - suspend fun setLastUsedCalendarId(id: Long) { - store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id } - } + suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id) - companion object { - internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids") - internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids") - internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending") - internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id") + private companion object { + val HIDDEN_IDS = idSetPref("hidden_calendar_ids") + val DISABLED_IDS = idSetPref("disabled_calendar_ids") + val VISIBILITY_NOTICE = nullableBooleanPref("visibility_notice_pending") + val LAST_USED_CALENDAR = nullableLongPref("last_used_calendar_id") } } -private fun String?.parseIds(): Set = orEmpty() - .split(',') - .mapNotNull { it.trim().toLongOrNull() } - .toSet() - -private fun MutablePreferences.writeIds(key: Preferences.Key, ids: Set) { - if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(",")) -} +/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */ +private fun idSetPref(name: String): Pref> = nullableStringPref(name).map( + { stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() }, + { ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/ReminderStatePrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/ReminderStatePrefs.kt index 7dc927a2..47c008f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/ReminderStatePrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/ReminderStatePrefs.kt @@ -2,10 +2,8 @@ package de.jeanlucmakiola.calendula.data.prefs import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.longPreferencesKey -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map +import de.jeanlucmakiola.floret.prefs.PrefStore +import de.jeanlucmakiola.floret.prefs.nullableLongPref import javax.inject.Inject import javax.inject.Singleton @@ -20,17 +18,16 @@ import javax.inject.Singleton */ @Singleton class ReminderStatePrefs @Inject constructor( - private val store: DataStore, + store: DataStore, ) { + private val prefs = PrefStore(store) /** The watermark, or `null` before the first scan has ever run. */ - suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first() + suspend fun lastScanMillis(): Long? = prefs.get(LAST_SCAN) - suspend fun setLastScanMillis(millis: Long) { - store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis } - } + suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis) private companion object { - val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis") + val LAST_SCAN = nullableLongPref("reminder_last_scan_millis") } } 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 5b66b081..883424be 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 @@ -2,16 +2,6 @@ package de.jeanlucmakiola.calendula.data.prefs import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import de.jeanlucmakiola.floret.android.stateWhileSubscribed -import de.jeanlucmakiola.floret.reminders.ReminderOverride -import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec -import de.jeanlucmakiola.floret.reminders.applyReminderOverride -import de.jeanlucmakiola.floret.reminders.normalizeReminders import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType @@ -26,24 +16,39 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale import de.jeanlucmakiola.calendula.ui.common.storageValue import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle +import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.widget.WidgetSize +import de.jeanlucmakiola.floret.android.stateWhileSubscribed +import de.jeanlucmakiola.floret.prefs.AppearancePrefs +import de.jeanlucmakiola.floret.prefs.Pref +import de.jeanlucmakiola.floret.prefs.PrefStore +import de.jeanlucmakiola.floret.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.booleanPref +import de.jeanlucmakiola.floret.prefs.enumPref +import de.jeanlucmakiola.floret.prefs.get +import de.jeanlucmakiola.floret.prefs.intPref +import de.jeanlucmakiola.floret.prefs.longPref +import de.jeanlucmakiola.floret.prefs.nullableLongPref +import de.jeanlucmakiola.floret.prefs.nullableStringPref +import de.jeanlucmakiola.floret.prefs.set +import de.jeanlucmakiola.floret.prefs.stringPref +import de.jeanlucmakiola.floret.prefs.toEnum +import de.jeanlucmakiola.floret.reminders.ReminderOverride +import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec +import de.jeanlucmakiola.floret.reminders.applyReminderOverride +import de.jeanlucmakiola.floret.reminders.normalizeReminders import java.time.ZoneId -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.datetime.DayOfWeek import java.time.temporal.WeekFields import java.util.Locale import javax.inject.Inject import javax.inject.Singleton - -/** - * Light/dark override. SYSTEM follows the device setting; BLACK is dark with - * pure-black surfaces, for AMOLED screens (#181). - */ -enum class ThemeMode { SYSTEM, LIGHT, DARK, BLACK } +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.datetime.DayOfWeek /** * Week-start override. [Auto] derives the first day from the active locale; @@ -71,7 +76,7 @@ enum class PastEventDisplay { SHOW, DIM, HIDE } fun parsePastEventDisplay( stored: String?, default: PastEventDisplay = PastEventDisplay.SHOW, -): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default +): PastEventDisplay = stored.toEnum(default) /** * Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the @@ -111,343 +116,126 @@ fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow = ) /** - * Display settings (M4) persisted app-side: theme override, Material You - * dynamic colour, and week start. Language is handled separately through - * AppCompatDelegate (which persists its own per-app locale). + * Display, view, editor, reminder, backup and special-dates settings, each a + * [SettingsKeys] entry read and written through floret's [PrefStore]. + * Language is handled separately through AppCompatDelegate (which persists its + * own per-app locale). * * Enum prefs round-trip by [Enum.name]; an unknown/garbage stored value falls * back to the default rather than throwing (see SettingsPrefsTest). */ @Singleton class SettingsPrefs @Inject constructor( - private val store: DataStore, + store: DataStore, ) { + private val prefs = PrefStore(store) - val themeMode: Flow = store.data.map { prefs -> - prefs[THEME_MODE_KEY].toEnum(ThemeMode.SYSTEM) - } + /** Every setting at once, for screens that read many; index it with [SettingsKeys]. */ + val data: Flow = prefs.data - val dynamicColor: Flow = store.data.map { prefs -> - prefs[DYNAMIC_COLOR_KEY] ?: true - } + val themeMode: Flow = prefs.flow(SettingsKeys.themeMode) + val dynamicColor: Flow = prefs.flow(SettingsKeys.dynamicColor) + val weekStart: Flow = prefs.flow(SettingsKeys.weekStart) + val softenCalendarColors: Flow = prefs.flow(SettingsKeys.softenCalendarColors) + val brandFont: Flow = prefs.flow(SettingsKeys.brandFont) + val plainFont: Flow = prefs.flow(SettingsKeys.plainFont) + val brandFontStamp: Flow = prefs.flow(SettingsKeys.brandFontStamp) + val plainFontStamp: Flow = prefs.flow(SettingsKeys.plainFontStamp) + val timeFormat: Flow = prefs.flow(SettingsKeys.timeFormat) + val showHourGrid: Flow = prefs.flow(SettingsKeys.showHourGrid) + val pastEventDisplay: Flow = prefs.flow(SettingsKeys.pastEventDisplay) + val dimCompletedEvents: Flow = prefs.flow(SettingsKeys.dimCompletedEvents) + val showWeekNumbers: Flow = prefs.flow(SettingsKeys.showWeekNumbers) + val monthViewStyle: Flow = prefs.flow(SettingsKeys.monthViewStyle) + val timelineScale: Flow = prefs.flow(SettingsKeys.timelineScale) + val multiDayCount: Flow = prefs.flow(SettingsKeys.multiDayCount) + val timelineHours: Flow = prefs.snapshot { it.timelineHours } + val todayButtonInToolbar: Flow = prefs.flow(SettingsKeys.todayButtonInToolbar) + val dragToReschedule: Flow = prefs.flow(SettingsKeys.dragToReschedule) + val tapOpensEditor: Flow = prefs.flow(SettingsKeys.tapOpensEditor) + val agendaScreenRange: Flow = prefs.flow(SettingsKeys.agendaScreenRange) + val agendaWidgetRange: Flow = prefs.flow(SettingsKeys.agendaWidgetRange) + val widgetSize: Flow = prefs.flow(SettingsKeys.widgetSize) + val agendaShowRangeBar: Flow = prefs.flow(SettingsKeys.agendaShowRangeBar) + val agendaShowToday: Flow = prefs.flow(SettingsKeys.agendaShowToday) + val defaultView: Flow = prefs.flow(SettingsKeys.defaultView) + val quickSwitchConfig: Flow = prefs.flow(SettingsKeys.quickSwitchConfig) + val drawerViewOrder: Flow> = prefs.flow(SettingsKeys.drawerViewOrder) + val defaultFormFields: Flow> = prefs.flow(SettingsKeys.defaultFormFields) + val recentTimeZones: Flow> = prefs.flow(SettingsKeys.recentTimeZones) + val autofocusEventTitle: Flow = prefs.flow(SettingsKeys.autofocusEventTitle) + val defaultEventDurationMinutes: Flow = prefs.flow(SettingsKeys.defaultEventDurationMinutes) + val perCalendarEventDuration: Flow> = prefs.flow(SettingsKeys.perCalendarEventDuration) + val remindersEnabled: Flow = prefs.flow(SettingsKeys.remindersEnabled) + val allowColorOnUnsupportedCalendars: Flow = + prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars) + val defaultReminderMinutes: Flow> = prefs.flow(SettingsKeys.defaultReminderMinutes) + val defaultAllDayReminderMinutes: Flow> = prefs.flow(SettingsKeys.defaultAllDayReminderMinutes) + val allDayReminderTimeMinutes: Flow = prefs.flow(SettingsKeys.allDayReminderTimeMinutes) + val snoozeMinutes: Flow = prefs.flow(SettingsKeys.snoozeMinutes) + val perCalendarReminderOverride: Flow>> = + prefs.flow(SettingsKeys.perCalendarReminderOverride) + val perCalendarAllDayReminderOverride: Flow>> = + prefs.flow(SettingsKeys.perCalendarAllDayReminderOverride) + val autoBackupEnabled: Flow = prefs.flow(SettingsKeys.autoBackupEnabled) + val autoBackupIntervalMinutes: Flow = prefs.flow(SettingsKeys.autoBackupIntervalMinutes) + val autoBackupFolderUri: Flow = prefs.flow(SettingsKeys.autoBackupFolderUri) + val autoBackupStatus: Flow = prefs.snapshot { it.autoBackupStatus } + val specialDatesEnabled: Flow = prefs.flow(SettingsKeys.specialDatesEnabled) + val specialDatesTypes: Flow> = prefs.snapshot { it.specialDatesTypes } + val specialDatesCalendars: Flow> = prefs.snapshot { it.specialDatesCalendars } - val weekStart: Flow = store.data.map { prefs -> - parseWeekStart(prefs[WEEK_START_KEY]) - } + /** Every managed calendar id — the editor locks title/date/recurrence for their events. */ + val managedCalendarIds: Flow> = prefs.snapshot { it.specialDatesCalendars.values.toSet() } + val specialDatesTitleTemplates: Flow> = + prefs.snapshot { it.specialDatesTitleTemplates } + val specialDatesShowYear: Flow = prefs.flow(SettingsKeys.specialDatesShowYear) + val specialDatesStatus: Flow = prefs.snapshot { it.specialDatesStatus } + val specialDatesLastForegroundSync: Flow = prefs.flow(SettingsKeys.specialDatesLastForegroundSync) - suspend fun setThemeMode(mode: ThemeMode) { - store.edit { it[THEME_MODE_KEY] = mode.name } - } - - suspend fun setDynamicColor(enabled: Boolean) { - store.edit { it[DYNAMIC_COLOR_KEY] = enabled } - } - - /** - * Whether raw provider colours are softened toward theme-fitting pastels - * before display (issue #36). Defaults to ON — the historical look, which - * caps harsh sync colours and pins brightness so entries read on both - * themes. Turning it off paints the calendar/event colour exactly as the - * sync source (DAVx5/CalDAV) publishes it. - */ - val softenCalendarColors: Flow = store.data.map { prefs -> - prefs[SOFTEN_COLORS_KEY] ?: true - } - - suspend fun setSoftenCalendarColors(enabled: Boolean) { - store.edit { it[SOFTEN_COLORS_KEY] = enabled } - } - - /** - * Custom-font tokens per Material typeface role (issue #19). Stored as opaque - * strings — "system", "custom", or a bundled font's token — resolved to a - * FontFamily at the theme layer; an unknown token degrades to the default. - */ - val brandFont: Flow = store.data.map { prefs -> - prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN - } - - val plainFont: Flow = store.data.map { prefs -> - prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN - } - - suspend fun setBrandFont(token: String) { - store.edit { it[BRAND_FONT_KEY] = token } - } - - suspend fun setPlainFont(token: String) { - store.edit { it[PLAIN_FONT_KEY] = token } - } - - /** - * A per-role bump counter for the user-loaded custom font. Re-importing - * overwrites the same file under the same "custom" token, so the token alone - * can't signal the change; this stamp — carried into AppFontSettings — breaks - * value equality so the resolved FontFamily (and its previews) refresh. A - * missing key is 0 (existing installs); the first import bumps it to 1. - */ - val brandFontStamp: Flow = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 } - - val plainFontStamp: Flow = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 } + suspend fun setThemeMode(mode: ThemeMode) = prefs.set(SettingsKeys.themeMode, mode) + suspend fun setDynamicColor(enabled: Boolean) = prefs.set(SettingsKeys.dynamicColor, enabled) + suspend fun setWeekStart(pref: WeekStartPref) = prefs.set(SettingsKeys.weekStart, pref) + suspend fun setSoftenCalendarColors(enabled: Boolean) = prefs.set(SettingsKeys.softenCalendarColors, enabled) + suspend fun setBrandFont(token: String) = prefs.set(SettingsKeys.brandFont, token) + suspend fun setPlainFont(token: String) = prefs.set(SettingsKeys.plainFont, token) /** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */ suspend fun bumpCustomFontStamp(role: FontRole) { - val key = when (role) { - FontRole.BRAND -> BRAND_FONT_STAMP_KEY - FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY + val stamp = when (role) { + FontRole.BRAND -> SettingsKeys.brandFontStamp + FontRole.PLAIN -> SettingsKeys.plainFontStamp } - store.edit { it[key] = (it[key] ?: 0) + 1 } + prefs.update(stamp) { it + 1 } } - suspend fun setWeekStart(pref: WeekStartPref) { - store.edit { it[WEEK_START_KEY] = pref.storageValue() } - } - - /** Clock convention for time-of-day labels (v2.11). Defaults to AUTO (system). */ - val timeFormat: Flow = store.data.map { prefs -> - prefs[TIME_FORMAT_KEY].toEnum(TimeFormatPref.AUTO) - } - - suspend fun setTimeFormat(pref: TimeFormatPref) { - store.edit { it[TIME_FORMAT_KEY] = pref.name } - } - - /** - * Whether the week/day timeline seats each hour in its own cell (v2.11, a - * separator line until v2.20). Defaults to OFF — the historical flat column; - * users opt in. The stored key keeps its original name. - */ - val showHourGrid: Flow = store.data.map { prefs -> - prefs[SHOW_HOUR_LINES_KEY] ?: false - } - - suspend fun setShowHourGrid(enabled: Boolean) { - store.edit { it[SHOW_HOUR_LINES_KEY] = enabled } - } - - /** - * How the Agenda screen treats events that already ended today. Defaults to - * [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming - * or hiding. - */ - val pastEventDisplay: Flow = store.data.map { prefs -> - prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW) - } - - suspend fun setPastEventDisplay(mode: PastEventDisplay) { - store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name } - } - - /** - * Whether the month/week grids fade events that have already finished. - * Defaults to OFF — independent of the Agenda's [pastEventDisplay]. - */ - val dimCompletedEvents: Flow = store.data.map { prefs -> - prefs[DIM_COMPLETED_EVENTS_KEY] ?: false - } - - suspend fun setDimCompletedEvents(enabled: Boolean) { - store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled } - } - - /** - * Whether the Month grid shows the calendar-week (ISO) number in a left - * gutter (#25). Defaults to OFF — users opt in, since it narrows the day - * cells slightly. The Week view shows its number unconditionally. - */ - val showWeekNumbers: Flow = store.data.map { prefs -> - prefs[SHOW_WEEK_NUMBERS_KEY] ?: false - } - - suspend fun setShowWeekNumbers(enabled: Boolean) { - store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled } - } - - /** - * How the Month view lays itself out (#38, #53). Defaults to [MonthViewStyle.Paged] - * — the historical behaviour, so existing installs see no change until they opt in. - */ - val monthViewStyle: Flow = store.data.map { prefs -> - prefs[MONTH_VIEW_STYLE_KEY].toEnum(MonthViewStyle.Paged) - } - - suspend fun setMonthViewStyle(style: MonthViewStyle) { - store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name } - } - - /** - * How tall an hour is drawn in the week and day timelines (#56). Defaults to - * [TimelineScale.Regular] — the historical 56dp scale. Holds either a preset - * or the height a pinch on the timeline settled at. - */ - val timelineScale: Flow = store.data.map { prefs -> - parseTimelineScale(prefs[TIMELINE_SCALE_KEY]) - } - - suspend fun setTimelineScale(scale: TimelineScale) { - store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() } - } - - /** How many days the multi-day view shows side by side (#193). */ - val multiDayCount: Flow = store.data.map { prefs -> - (prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS) - } - - suspend fun setMultiDayCount(count: Int) { - store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) } - } - - /** - * The hours the day, week and multi-day timelines fit to the screen when they - * open (#322). Off by default; the hours survive switching it off. - */ - val timelineHours: Flow = store.data.map { prefs -> - TimelineHours.of( - enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false, - startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR, - endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR, - ) - } + suspend fun setTimeFormat(pref: TimeFormatPref) = prefs.set(SettingsKeys.timeFormat, pref) + suspend fun setShowHourGrid(enabled: Boolean) = prefs.set(SettingsKeys.showHourGrid, enabled) + suspend fun setPastEventDisplay(mode: PastEventDisplay) = prefs.set(SettingsKeys.pastEventDisplay, mode) + suspend fun setDimCompletedEvents(enabled: Boolean) = prefs.set(SettingsKeys.dimCompletedEvents, enabled) + suspend fun setShowWeekNumbers(enabled: Boolean) = prefs.set(SettingsKeys.showWeekNumbers, enabled) + suspend fun setMonthViewStyle(style: MonthViewStyle) = prefs.set(SettingsKeys.monthViewStyle, style) + suspend fun setTimelineScale(scale: TimelineScale) = prefs.set(SettingsKeys.timelineScale, scale) + suspend fun setMultiDayCount(count: Int) = prefs.set(SettingsKeys.multiDayCount, count) suspend fun setTimelineHours(hours: TimelineHours) { val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour) - store.edit { - it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled - it[TIMELINE_HOURS_START_KEY] = valid.startHour - it[TIMELINE_HOURS_END_KEY] = valid.endHour + prefs.edit { + it[SettingsKeys.timelineHoursEnabled] = valid.enabled + it[SettingsKeys.timelineHoursStart] = valid.startHour + it[SettingsKeys.timelineHoursEnd] = valid.endHour } } - /** - * Where the jump-to-today control lives (issue #60). Default OFF — the - * historical layout, where it's an extended FAB that fades in above the "+" - * only while the view is off today. Turning it ON moves it to a persistent - * icon button in each calendar view's top bar (always shown, on today or - * not) and drops the FAB pill. - */ - val todayButtonInToolbar: Flow = store.data.map { prefs -> - prefs[TODAY_BUTTON_IN_TOOLBAR_KEY] ?: false - } - - suspend fun setTodayButtonInToolbar(enabled: Boolean) { - store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled } - } - - /** - * Whether events can be dragged to another slot in the calendar views (#68, - * #173). Defaults to ON. Off means no block registers a drag gesture at all; - * rescheduling then goes through the edit form. - */ - val dragToReschedule: Flow = store.data.map { prefs -> - prefs[DRAG_TO_RESCHEDULE_KEY] ?: true - } - - suspend fun setDragToReschedule(enabled: Boolean) { - store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled } - } - - /** - * Whether tapping an event in the calendar views opens the edit form instead - * of the detail screen (#178). Defaults to OFF; read-only events and - * reminder notifications always open the detail screen. - */ - val tapOpensEditor: Flow = store.data.map { prefs -> - prefs[TAP_OPENS_EDITOR_KEY] ?: false - } - - suspend fun setTapOpensEditor(enabled: Boolean) { - store.edit { it[TAP_OPENS_EDITOR_KEY] = enabled } - } - - /** - * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to - * [AgendaRange.Month] — a month of upcoming events. Independent of the - * widget's [agendaWidgetRange]. - */ - val agendaScreenRange: Flow = store.data.map { prefs -> - parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month) - } - - suspend fun setAgendaScreenRange(range: AgendaRange) { - store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() } - } - - /** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */ - val agendaWidgetRange: Flow = store.data.map { prefs -> - parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month) - } - - suspend fun setAgendaWidgetRange(range: AgendaRange) { - store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() } - } - - /** - * The size step the agenda widget draws its text at (#51). Defaults to - * [WidgetSize.SMALL], which reproduces its original metrics, so an existing - * widget is unchanged until its owner turns the size up. - * - * This replaced deriving a size tier from the widget's measured size, which - * the launcher does not report reliably. The month widget takes no size - * setting — it divides the width it is given by seven (#103). - */ - val widgetSize: Flow = store.data.map { prefs -> - prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL) - } - - suspend fun setWidgetSize(size: WidgetSize) { - store.edit { it[WIDGET_SIZE_KEY] = size.name } - } - - /** - * Whether the agenda shows its top range bar — the "showing …" header and - * the session range switcher (v2.11). Default ON. - */ - val agendaShowRangeBar: Flow = store.data.map { prefs -> - prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true - } - - suspend fun setAgendaShowRangeBar(enabled: Boolean) { - store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled } - } - - /** - * Whether the agenda (both the in-app screen and the home-screen widget) - * always anchors today at the top with a "nothing left today" placeholder, - * even once today has no remaining events (issue #35). Default ON — makes - * today's events easy to tell apart from a future day's at a glance. - */ - val agendaShowToday: Flow = store.data.map { prefs -> - prefs[AGENDA_SHOW_TODAY_KEY] ?: true - } - - suspend fun setAgendaShowToday(enabled: Boolean) { - store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled } - } - - /** - * The calendar view the app opens on (M1). Defaults to [CalendarView.Week] — - * the historical hard-coded startup view — so existing users see no change - * until they pick another. Also the bottom of the in-app view back stack, so - * back from any other view returns here before exiting. - */ - val defaultView: Flow = store.data.map { prefs -> - prefs[DEFAULT_VIEW_KEY].toEnum(CalendarView.Week) - } - - suspend fun setDefaultView(view: CalendarView) { - store.edit { it[DEFAULT_VIEW_KEY] = view.name } - } - - /** - * Quick-switch button customisation (#24): the ordered full view list plus - * which views are enabled in the cycle. Stored comma-joined by enum name, a - * "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing - * views are appended enabled and unknown names dropped, so a future view - * defaults into the cycle. An absent key means [QuickSwitchConfig.Default]. - */ - val quickSwitchConfig: Flow = store.data.map { prefs -> - parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY]) - } + suspend fun setTodayButtonInToolbar(enabled: Boolean) = prefs.set(SettingsKeys.todayButtonInToolbar, enabled) + suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled) + suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled) + suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range) + suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range) + suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size) + suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled) + suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled) + suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view) /** * Atomic read-modify-write of the quick-switch config: [transform] sees the @@ -456,654 +244,613 @@ class SettingsPrefs @Inject constructor( * instead of each re-serialising a stale copy of the other field over it. */ suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) { - store.edit { prefs -> - val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY]) - prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current)) - } + prefs.update(SettingsKeys.quickSwitchConfig, transform) } - /** - * Navigation-drawer view order (#24). Comma-joined enum names; missing views - * are appended in default order and unknown names dropped. Absent key means - * [IMPLEMENTED_VIEWS] (the historical fixed order). - */ - val drawerViewOrder: Flow> = store.data.map { prefs -> - parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY]) - } - - suspend fun setDrawerViewOrder(order: List) { - store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } } - } - - /** - * Optional event-form fields shown by default (the rest hide behind - * "more fields"). Stored comma-joined by enum name: an absent key means - * the factory default, an empty string means "none". Unknown names are - * dropped defensively, like the other enum prefs. - */ - val defaultFormFields: Flow> = store.data.map { prefs -> - parseFormFields(prefs[FORM_FIELDS_KEY]) - } + suspend fun setDrawerViewOrder(order: List) = prefs.set(SettingsKeys.drawerViewOrder, order) suspend fun setFormFieldDefault(field: EventFormField, enabled: Boolean) { - store.edit { prefs -> - val current = parseFormFields(prefs[FORM_FIELDS_KEY]) - val updated = if (enabled) current + field else current - field - prefs[FORM_FIELDS_KEY] = updated.joinToString(",") { it.name } - } - } - - /** - * Zones the user picked recently, most recent first — the timezone picker's - * default list, since scrolling ~600 zones to re-find the two you actually - * use is the whole problem. Stored comma-joined (IANA ids never contain a - * comma); unparseable ids are dropped on read, so a zone the tz database - * later retires can't wedge the list. - */ - val recentTimeZones: Flow> = store.data.map { prefs -> - parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]) + prefs.update(SettingsKeys.defaultFormFields) { if (enabled) it + field else it - field } } suspend fun addRecentTimeZone(zoneId: String) { - store.edit { prefs -> - val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])) - .distinct() - .take(MAX_RECENT_TIME_ZONES) - prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",") - } + prefs.update(SettingsKeys.recentTimeZones) { (listOf(zoneId) + it).distinct() } } - /** - * Whether opening the new-event form focuses the title field and raises the - * keyboard straight away (issue #10). Default ON — a new event almost always - * gets a title, so this saves a tap; users who set the time/calendar first - * can turn it off. Only the create form auto-focuses — editing never does. - */ - val autofocusEventTitle: Flow = store.data.map { prefs -> - prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true - } + suspend fun setAutofocusEventTitle(enabled: Boolean) = prefs.set(SettingsKeys.autofocusEventTitle, enabled) - suspend fun setAutofocusEventTitle(enabled: Boolean) { - store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled } - } - - /** - * How long a new **timed** event lasts, in minutes (#54). Defaults to - * [DEFAULT_EVENT_DURATION] — the historical fixed hour. Per-calendar - * overrides in [perCalendarEventDuration] take precedence; all-day events are - * date-anchored and ignore it. Resolve with [resolveDefaultEventDuration]. - */ - val defaultEventDurationMinutes: Flow = store.data.map { prefs -> - (prefs[DEFAULT_EVENT_DURATION_KEY] ?: DEFAULT_EVENT_DURATION) - .coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION) - } - - suspend fun setDefaultEventDurationMinutes(minutes: Int) { - store.edit { - it[DEFAULT_EVENT_DURATION_KEY] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION) - } - } - - /** - * Per-calendar overrides of [defaultEventDurationMinutes], keyed by calendar - * id: a calendar **present** in the map gives its new events that length, one - * **absent** inherits the global default (there is no "no duration", so this - * needs no `none` sentinel). Serialised as `id=minutes;id=minutes`. - */ - val perCalendarEventDuration: Flow> = store.data.map { prefs -> - parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]) - } + suspend fun setDefaultEventDurationMinutes(minutes: Int) = + prefs.set(SettingsKeys.defaultEventDurationMinutes, minutes) /** [minutes] null drops the override, so the calendar inherits the global default. */ suspend fun setCalendarEventDuration(calendarId: Long, minutes: Int?) { - store.edit { prefs -> - val current = parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]).toMutableMap() + prefs.update(SettingsKeys.perCalendarEventDuration) { current -> if (minutes == null) { - current.remove(calendarId) + current - calendarId } else { - current[calendarId] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION) + current + (calendarId to minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)) } - prefs[CALENDAR_EVENT_DURATION_KEY] = current.toStoredDurations() } } - /** - * Whether Calendula posts reminder notifications (v1.4). Defaults to ON — - * for users whose only calendar app this is, reminders are essential; the - * onboarding step and Settings warn about duplicates from a second app. - */ - val remindersEnabled: Flow = store.data.map { prefs -> - prefs[REMINDERS_ENABLED_KEY] ?: true - } + suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled) - suspend fun setRemindersEnabled(enabled: Boolean) { - store.edit { it[REMINDERS_ENABLED_KEY] = enabled } - } + suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) = + prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled) - /** - * Whether to offer a custom event colour even on calendars that publish no - * colour palette (most local calendars handle it fine; synced calendars - * without a palette — some CalDAV — may drop or overwrite a raw colour on - * their next sync). Defaults to OFF: such calendars hide the colour picker - * until the user opts in, accepting the limitation. Local calendars and - * palette-backed calendars (Google, …) are unaffected by this flag. - */ - val allowColorOnUnsupportedCalendars: Flow = store.data.map { prefs -> - prefs[ALLOW_COLOR_UNSUPPORTED_KEY] ?: false - } - - suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) { - store.edit { it[ALLOW_COLOR_UNSUPPORTED_KEY] = enabled } - } - - /** - * Whether the one-time reminder onboarding step (after the calendar - * grant) has been shown — also true for users who tapped "not now". - */ - val reminderOnboardingDone: Flow = store.data.map { prefs -> - prefs[REMINDER_ONBOARDING_KEY] ?: false - } + /** Which wizard steps have been answered (or skipped), and whether the wizard is armed. */ + val onboardingProgress: Flow = prefs.snapshot { it.onboardingProgress } /** Cleared, not just set: the wizard's back button re-asks a step (#163). */ - suspend fun setReminderOnboardingDone(done: Boolean = true) { - store.edit { it[REMINDER_ONBOARDING_KEY] = done } + suspend fun setOnboardingDone(step: OnboardingStep, done: Boolean = true) { + val flag = SettingsKeys.onboardingDone[step] ?: return + prefs.set(flag, done) } /** - * Whether this install is a fresh one still owing the wizard's optional - * steps (#163). Armed on any launch that holds the calendar permission and - * has not finished the reminder step, and cleared again by - * [finishOnboardingWizard], so an existing user who revokes and re-grants - * the permission isn't re-onboarded. + * Arm the wizard's optional steps for an install that has not finished the + * reminder step (#163); see [SettingsKeys.onboardingWizardArmed]. */ - val onboardingWizardArmed: Flow = store.data.map { prefs -> - prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false - } - suspend fun armOnboardingWizard() { - store.edit { prefs -> - if (prefs[REMINDER_ONBOARDING_KEY] != true) prefs[ONBOARDING_WIZARD_ARMED_KEY] = true + prefs.edit { + if (!it[SettingsKeys.reminderOnboardingDone]) it[SettingsKeys.onboardingWizardArmed] = true } } - /** Whether the wizard's calendar step has been answered (or skipped). */ - val onboardingCalendarsDone: Flow = store.data.map { prefs -> - prefs[ONBOARDING_CALENDARS_KEY] ?: false - } - - suspend fun setOnboardingCalendarsDone(done: Boolean = true) { - store.edit { it[ONBOARDING_CALENDARS_KEY] = done } - } - - /** Whether the wizard's hidden-calendars step has been answered (or skipped). */ - val onboardingHiddenCalendarsDone: Flow = store.data.map { prefs -> - prefs[ONBOARDING_HIDDEN_KEY] ?: false - } - - suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) { - store.edit { it[ONBOARDING_HIDDEN_KEY] = done } - } - - /** Whether the wizard's backup step has been answered (or skipped). */ - val onboardingBackupDone: Flow = store.data.map { prefs -> - prefs[ONBOARDING_BACKUP_KEY] ?: false - } - - suspend fun setOnboardingBackupDone(done: Boolean = true) { - store.edit { it[ONBOARDING_BACKUP_KEY] = done } - } - - /** Whether the wizard's default-view step has been answered (or skipped). */ - val onboardingViewDone: Flow = store.data.map { prefs -> - prefs[ONBOARDING_VIEW_KEY] ?: false - } - - suspend fun setOnboardingViewDone(done: Boolean = true) { - store.edit { it[ONBOARDING_VIEW_KEY] = done } - } - - /** Whether the wizard's month-style step has been answered (or skipped). */ - val onboardingMonthStyleDone: Flow = store.data.map { prefs -> - prefs[ONBOARDING_MONTH_STYLE_KEY] ?: false - } - - suspend fun setOnboardingMonthStyleDone(done: Boolean = true) { - store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done } - } - - /** - * Whether the wizard's calendar-visibility notice has been read. Separate - * from [CalendarPrefs.visibilityNoticePending], which says whether this - * install has anything to announce at all. - */ - val onboardingVisibilityDone: Flow = store.data.map { prefs -> - prefs[ONBOARDING_VISIBILITY_KEY] ?: false - } - - suspend fun setOnboardingVisibilityDone(done: Boolean = true) { - store.edit { it[ONBOARDING_VISIBILITY_KEY] = done } - } - - /** Whether the wizard's closing screen has been seen. */ - val onboardingDoneShown: Flow = store.data.map { prefs -> - prefs[ONBOARDING_DONE_KEY] ?: false - } - - suspend fun setOnboardingDoneShown(shown: Boolean = true) { - store.edit { it[ONBOARDING_DONE_KEY] = shown } - } - /** * Close the wizard for good: the closing screen has been seen and the * install stops counting as fresh, so a later revoke-and-re-grant is only * the permission screen. One edit, so the plan can't see it half-closed. */ suspend fun finishOnboardingWizard() { - store.edit { prefs -> - prefs[ONBOARDING_DONE_KEY] = true - prefs[ONBOARDING_WIZARD_ARMED_KEY] = false + prefs.edit { + it[SettingsKeys.onboardingDoneShown] = true + it[SettingsKeys.onboardingWizardArmed] = false } } - /** - * The default reminder lead times (minutes before start) prefilled on new - * **timed** events. The empty list = no default reminder — the prior - * behaviour, kept as the factory default so existing users aren't surprised by - * reminders they never asked for. Stored as a comma-joined list of minutes, or - * "none"/empty for no reminder (also the unset state). A legacy single value - * ("30") parses transparently to a one-element list. Per-calendar overrides in - * [perCalendarReminderOverride] take precedence; all-day events instead use - * [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder]. - */ - val defaultReminderMinutes: Flow> = store.data.map { prefs -> - prefs[DEFAULT_REMINDER_KEY].toReminderList() + suspend fun setDefaultReminderMinutes(minutes: List) = + prefs.set(SettingsKeys.defaultReminderMinutes, minutes) + + suspend fun setDefaultAllDayReminderMinutes(minutes: List) = + prefs.set(SettingsKeys.defaultAllDayReminderMinutes, minutes) + + suspend fun setAllDayReminderTimeMinutes(minutesOfDay: Int) = + prefs.set(SettingsKeys.allDayReminderTimeMinutes, minutesOfDay) + + suspend fun setSnoozeMinutes(minutes: Int) = prefs.set(SettingsKeys.snoozeMinutes, minutes) + + suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) = + prefs.applyOverride(SettingsKeys.perCalendarReminderOverride, calendarId, override) + + suspend fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) = + prefs.applyOverride(SettingsKeys.perCalendarAllDayReminderOverride, calendarId, override) + + suspend fun setAutoBackupEnabled(enabled: Boolean) = prefs.set(SettingsKeys.autoBackupEnabled, enabled) + + suspend fun setAutoBackupIntervalMinutes(minutes: Long) = + prefs.set(SettingsKeys.autoBackupIntervalMinutes, minutes) + + suspend fun setAutoBackupFolderUri(uri: String?) = prefs.set(SettingsKeys.autoBackupFolderUri, uri) + + /** Record an automatic run's outcome; resets the failure streak on success. */ + suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) { + prefs.edit { + it[SettingsKeys.autoBackupLastRun] = atMillis + it[SettingsKeys.autoBackupLastSuccess] = success + it[SettingsKeys.autoBackupFailures] = if (success) 0 else it[SettingsKeys.autoBackupFailures] + 1 + } } - suspend fun setDefaultReminderMinutes(minutes: List) { - store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() } + suspend fun setSpecialDatesEnabled(enabled: Boolean) = prefs.set(SettingsKeys.specialDatesEnabled, enabled) + + suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) = + prefs.set(SettingsKeys.specialDateTypeEnabled.getValue(type), enabled) + + suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) = + prefs.set(SettingsKeys.specialDatesCalendarId.getValue(type), calendarId) + + suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) = + prefs.set(SettingsKeys.specialDatesTitleTemplate.getValue(type), template) + + suspend fun setSpecialDatesShowYear(enabled: Boolean) = prefs.set(SettingsKeys.specialDatesShowYear, enabled) + + /** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */ + suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) { + prefs.edit { + it[SettingsKeys.specialDatesLastRun] = atMillis + it[SettingsKeys.specialDatesStalled] = stalled + } } + suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) = + prefs.set(SettingsKeys.specialDatesLastForegroundSync, atMillis) + + companion object { + /** A new timed event's length until the user changes it: one hour. */ + const val DEFAULT_EVENT_DURATION = 60 + internal const val MIN_EVENT_DURATION = 1 + /** A day — past that the form is describing a multi-day event, not a default. */ + internal const val MAX_EVENT_DURATION = 1_440 + /** 09:00 as minutes from midnight; the default all-day reminder fire time. */ + internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540 + internal const val MINUTES_PER_DAY = 1_440 + /** Default snooze delay for the notification "Snooze" action. */ + const val DEFAULT_SNOOZE_MINUTES = 10 + const val DEFAULT_MULTI_DAY_COUNT = 3 + val MULTI_DAY_COUNTS = 3..6 + internal val DEFAULT_FORM_FIELDS = + setOf(EventFormField.Location, EventFormField.Description) + /** Enough to cover the zones a user actually recurs to, without a wall of rows. */ + internal const val MAX_RECENT_TIME_ZONES = 5 + /** Default automatic-backup interval: daily. */ + const val DEFAULT_BACKUP_INTERVAL = 1_440L + /** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */ + const val MIN_BACKUP_INTERVAL = 30L + } +} + +/** + * Every setting [SettingsPrefs] stores. The names are on-disk data, fixed at the + * release that introduced each one; renaming one is a migration. + */ +object SettingsKeys { + val themeMode: Pref = AppearancePrefs.themeMode + val dynamicColor: Pref = AppearancePrefs.dynamicColor + + /** "AUTO" (or unset) follows the locale; a [DayOfWeek] name pins that day. */ + val weekStart: Pref = + nullableStringPref("week_start").map(::parseWeekStart) { it.storageValue() } + /** - * The default reminder lead times prefilled on new **all-day** events, in - * minutes before the start of the day. All-day events want day-scale lead - * times ("1 day before"), so they have their own default rather than reusing - * the timed one. Empty list = no default. Per-calendar overrides do **not** - * apply to all-day events — they always use this global value. + * Whether raw provider colours are softened toward theme-fitting pastels + * before display (issue #36). Defaults to ON — the historical look. Turning + * it off paints the calendar/event colour exactly as the sync source + * (DAVx5/CalDAV) publishes it. */ - val defaultAllDayReminderMinutes: Flow> = store.data.map { prefs -> - prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList() - } + val softenCalendarColors: Pref = booleanPref("soften_calendar_colors", true) - suspend fun setDefaultAllDayReminderMinutes(minutes: List) { - store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() } - } + /** + * Custom-font tokens per Material typeface role (issue #19): "system", + * "custom", or a bundled font's token, resolved to a FontFamily at the theme + * layer; an unknown token degrades to the default. + */ + val brandFont: Pref = stringPref("brand_font", FONT_SYSTEM_TOKEN) + val plainFont: Pref = stringPref("plain_font", FONT_SYSTEM_TOKEN) + + /** + * A per-role bump counter for the user-loaded custom font. Re-importing + * overwrites the same file under the same "custom" token, so the token alone + * can't signal the change; this stamp — carried into AppFontSettings — breaks + * value equality so the resolved FontFamily (and its previews) refresh. + */ + val brandFontStamp: Pref = intPref("brand_font_stamp", 0) + val plainFontStamp: Pref = intPref("plain_font_stamp", 0) + + /** Clock convention for time-of-day labels (v2.11). */ + val timeFormat: Pref = enumPref("time_format", TimeFormatPref.AUTO) + + /** + * Whether the week/day timeline seats each hour in its own cell (v2.11, a + * separator line until v2.20). The stored key keeps its original name. + */ + val showHourGrid: Pref = booleanPref("show_hour_lines", false) + + /** How the Agenda screen treats events that already ended today. */ + val pastEventDisplay: Pref = + enumPref("agenda_past_event_display", PastEventDisplay.SHOW) + + /** Whether the month/week grids fade finished events; independent of [pastEventDisplay]. */ + val dimCompletedEvents: Pref = booleanPref("dim_completed_events", false) + + /** + * Whether the Month grid shows the ISO week number in a left gutter (#25). + * The Week view shows its number unconditionally. + */ + val showWeekNumbers: Pref = booleanPref("show_week_numbers", false) + + /** How the Month view lays itself out (#38, #53). */ + val monthViewStyle: Pref = enumPref("month_view_style", MonthViewStyle.Paged) + + /** + * How tall an hour is drawn in the week and day timelines (#56): a preset or + * the height a pinch on the timeline settled at. + */ + val timelineScale: Pref = + nullableStringPref("timeline_scale").map(::parseTimelineScale) { it.storageValue() } + + /** How many days the multi-day view shows side by side (#193). */ + val multiDayCount: Pref = + intPref("multi_day_count", SettingsPrefs.DEFAULT_MULTI_DAY_COUNT).coerced { + it.coerceIn(SettingsPrefs.MULTI_DAY_COUNTS) + } + + /** + * The hours the day, week and multi-day timelines fit to the screen when they + * open (#322). Off by default; the hours survive switching it off. Read + * together as [Preferences.timelineHours]. + */ + val timelineHoursEnabled: Pref = booleanPref("timeline_hours_enabled", false) + val timelineHoursStart: Pref = intPref("timeline_hours_start", TimelineHours.DEFAULT_START_HOUR) + val timelineHoursEnd: Pref = intPref("timeline_hours_end", TimelineHours.DEFAULT_END_HOUR) + + /** + * Where the jump-to-today control lives (issue #60). Off is an extended FAB + * above the "+" while the view is off today; on is a persistent icon button + * in each calendar view's top bar. + */ + val todayButtonInToolbar: Pref = booleanPref("today_button_in_toolbar", false) + + /** + * Whether events can be dragged to another slot in the calendar views (#68, + * #173). Off means no block registers a drag gesture at all. + */ + val dragToReschedule: Pref = booleanPref("drag_to_reschedule", true) + + /** + * Whether tapping an event in the calendar views opens the edit form instead + * of the detail screen (#178). Read-only events and reminder notifications + * always open the detail screen. + */ + val tapOpensEditor: Pref = booleanPref("tap_opens_editor", false) + + /** How far ahead the in-app Agenda screen shows events (v2.11). */ + val agendaScreenRange: Pref = agendaRangePref("agenda_screen_range") + + /** How far ahead the agenda **widget** shows events (v2.11). */ + val agendaWidgetRange: Pref = agendaRangePref("agenda_widget_range") + + /** + * The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL] + * reproduces its original metrics. The month widget takes no size setting — + * it divides the width it is given by seven (#103). + */ + val widgetSize: Pref = enumPref("widget_size", WidgetSize.SMALL) + + /** Whether the agenda shows its "showing …" header and range switcher (v2.11). */ + val agendaShowRangeBar: Pref = booleanPref("agenda_show_range_bar", true) + + /** + * Whether the agenda (screen and widget) always anchors today at the top with + * a "nothing left today" placeholder (issue #35). + */ + val agendaShowToday: Pref = booleanPref("agenda_show_today", true) + + /** + * The calendar view the app opens on (M1), and the bottom of the in-app view + * back stack. [CalendarView.Week] was the historical hard-coded startup view. + */ + val defaultView: Pref = enumPref("default_view", CalendarView.Week) + + /** + * Quick-switch button customisation (#24): the ordered full view list plus + * which views are enabled in the cycle. Stored comma-joined by enum name, a + * "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing + * views are appended enabled and unknown names dropped, so a future view + * defaults into the cycle. + */ + val quickSwitchConfig: Pref = + nullableStringPref("quick_switch_views").map(::parseQuickSwitch, ::serializeQuickSwitch) + + /** + * Navigation-drawer view order (#24), comma-joined enum names; missing views + * are appended in default order and unknown names dropped. + */ + val drawerViewOrder: Pref> = + nullableStringPref("drawer_view_order").map(::parseViewOrder) { order -> order.joinToString(",") { it.name } } + + /** + * Optional event-form fields shown by default (the rest hide behind "more + * fields"), comma-joined by enum name: an absent key means the factory + * default, an empty string means "none". + */ + val defaultFormFields: Pref> = + nullableStringPref("event_form_default_fields").map(::parseFormFields) { fields -> + fields.joinToString(",") { it.name } + } + + /** + * Zones the user picked recently, most recent first — the timezone picker's + * default list. Stored comma-joined (IANA ids never contain a comma); + * unparseable ids are dropped on read, so a zone the tz database later + * retires can't wedge the list. + */ + val recentTimeZones: Pref> = + nullableStringPref("recent_time_zones").map(::parseRecentTimeZones) { zones -> + zones.take(SettingsPrefs.MAX_RECENT_TIME_ZONES).joinToString(",") + } + + /** + * Whether opening the new-event form focuses the title field and raises the + * keyboard straight away (issue #10). Only the create form auto-focuses. + */ + val autofocusEventTitle: Pref = booleanPref("autofocus_event_title", true) + + /** + * How long a new **timed** event lasts, in minutes (#54). Per-calendar + * overrides in [perCalendarEventDuration] take precedence; all-day events + * ignore it. Resolve with [resolveDefaultEventDuration]. + */ + val defaultEventDurationMinutes: Pref = + intPref("default_event_duration_minutes", SettingsPrefs.DEFAULT_EVENT_DURATION).coerced { + it.coerceIn(SettingsPrefs.MIN_EVENT_DURATION, SettingsPrefs.MAX_EVENT_DURATION) + } + + /** + * Per-calendar overrides of [defaultEventDurationMinutes]: a calendar present + * in the map gives its new events that length, one absent inherits the + * global default. Serialised as `id=minutes;id=minutes`. + */ + val perCalendarEventDuration: Pref> = + nullableStringPref("per_calendar_event_duration").map(::parseDurationOverrides) { it.toStoredDurations() } + + /** + * Whether Calendula posts reminder notifications (v1.4). On by default — for + * users whose only calendar app this is, reminders are essential. + */ + val remindersEnabled: Pref = booleanPref("reminders_enabled", true) + + /** + * Whether to offer a custom event colour on calendars that publish no colour + * palette; synced ones without a palette (some CalDAV) may drop or overwrite + * a raw colour on their next sync. Local and palette-backed calendars are + * unaffected. + */ + val allowColorOnUnsupportedCalendars: Pref = + booleanPref("allow_color_unsupported_calendars", false) + + /** Whether the reminder onboarding step has been shown — also true after "not now". */ + val reminderOnboardingDone: Pref = booleanPref("reminder_onboarding_done", false) + + /** + * Whether this install is a fresh one still owing the wizard's optional + * steps (#163). Armed on any launch that holds the calendar permission and + * has not finished the reminder step, and cleared again by + * [SettingsPrefs.finishOnboardingWizard], so an existing user who revokes and + * re-grants the permission isn't re-onboarded. + */ + val onboardingWizardArmed: Pref = booleanPref("onboarding_wizard_armed", false) + + /** Whether the wizard's closing screen has been seen. */ + val onboardingDoneShown: Pref = booleanPref("onboarding_done_shown", false) + + /** + * Whether each wizard step has been answered (or skipped). The visibility + * flag is separate from [CalendarPrefs.visibilityNoticePending], which says + * whether this install has anything to announce at all. + */ + val onboardingDone: Map> = mapOf( + OnboardingStep.HiddenCalendars to booleanPref("onboarding_hidden_calendars_done", false), + OnboardingStep.Calendars to booleanPref("onboarding_calendars_done", false), + OnboardingStep.Reminders to reminderOnboardingDone, + OnboardingStep.Backup to booleanPref("onboarding_backup_done", false), + OnboardingStep.View to booleanPref("onboarding_view_done", false), + OnboardingStep.MonthStyle to booleanPref("onboarding_month_style_done", false), + OnboardingStep.Visibility to booleanPref("onboarding_visibility_done", false), + OnboardingStep.Done to onboardingDoneShown, + ) + + /** + * The default reminder lead times prefilled on new **timed** events; empty + * means none, the factory default. Stored comma-joined, or "none"/empty for + * no reminder; a legacy single value ("30") parses to a one-element list. + * Per-calendar overrides in [perCalendarReminderOverride] take precedence. + * Resolve with [resolveDefaultReminder]. + */ + val defaultReminderMinutes: Pref> = reminderListPref("default_reminder_minutes") + + /** + * The default lead times prefilled on new **all-day** events, in minutes + * before the start of the day — day-scale ("1 day before"), so separate from + * the timed default. + */ + val defaultAllDayReminderMinutes: Pref> = reminderListPref("default_allday_reminder_minutes") /** * Wall-clock time, as minutes from local midnight, at which **all-day** * reminders fire. All-day events live at UTC midnight, so a raw "1 day * before" would fire at an off hour (02:00 local in CEST); this time is * encoded into the provider offset so the reminder lands at, e.g., 09:00 the - * day before instead. Global for every all-day reminder; default 09:00. - * Stored/clamped to a valid 0..1439 minute-of-day. + * day before instead. */ - val allDayReminderTimeMinutes: Flow = store.data.map { prefs -> - (prefs[ALLDAY_REMINDER_TIME_KEY] ?: DEFAULT_ALLDAY_REMINDER_TIME) - .coerceIn(0, MINUTES_PER_DAY - 1) - } - - suspend fun setAllDayReminderTimeMinutes(minutesOfDay: Int) { - store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) } - } - - /** - * How long the notification "Snooze" action defers a reminder, in minutes. - * Tapping Snooze cancels the notification and re-posts it after this delay - * via an exact alarm (the calendar provider won't re-fire). Default 10 min; - * clamped to at least 1. - */ - val snoozeMinutes: Flow = store.data.map { prefs -> - (prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1) - } - - suspend fun setSnoozeMinutes(minutes: Int) { - store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(1) } - } - - /** - * Per-calendar overrides of [defaultReminderMinutes] for **timed** events, - * keyed by calendar id. A calendar **present** in the map overrides the global - * timed default for its new events: an **empty** list means "no reminder", a - * non-empty list means those lead times. A calendar **absent** from the map - * inherits the global default. Serialised as `id=value;id=value`, where a value - * is a comma-joined minute list or `none` for an explicit no-reminder override. - * (All-day events ignore this and use [defaultAllDayReminderMinutes].) - */ - val perCalendarReminderOverride: Flow>> = store.data.map { prefs -> - reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]) - } - - suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) { - store.edit { prefs -> - val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap() - current.applyReminderOverride(calendarId, override) - prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current) + val allDayReminderTimeMinutes: Pref = + intPref("allday_reminder_time_minutes", SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME).coerced { + it.coerceIn(0, SettingsPrefs.MINUTES_PER_DAY - 1) } - } /** - * Per-calendar overrides of [defaultAllDayReminderMinutes] for **all-day** - * events, with the same semantics as [perCalendarReminderOverride] (absent = - * inherit the global all-day default; present null = no reminder). + * How long the notification "Snooze" action defers a reminder, in minutes, + * re-posted via an exact alarm (the calendar provider won't re-fire). */ - val perCalendarAllDayReminderOverride: Flow>> = store.data.map { prefs -> - reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]) - } + val snoozeMinutes: Pref = + intPref("snooze_minutes", SettingsPrefs.DEFAULT_SNOOZE_MINUTES).coerced { it.coerceAtLeast(1) } - suspend fun setCalendarAllDayReminderOverride( - calendarId: Long, - override: ReminderOverride, - ) { - store.edit { prefs -> - val current = - reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap() - current.applyReminderOverride(calendarId, override) - prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current) + /** + * Per-calendar overrides of [defaultReminderMinutes] for **timed** events. A + * calendar present in the map overrides the global default: an empty list + * means "no reminder". A calendar absent inherits the global default. + * Serialised as `id=value;id=value`, a value being a comma-joined minute list + * or `none`. + */ + val perCalendarReminderOverride: Pref>> = + reminderOverridePref("per_calendar_reminder_override") + + /** Per-calendar overrides of [defaultAllDayReminderMinutes], as [perCalendarReminderOverride]. */ + val perCalendarAllDayReminderOverride: Pref>> = + reminderOverridePref("per_calendar_allday_reminder_override") + + /** Whether periodic automatic `.ics` export of local calendars is on (issue #8). */ + val autoBackupEnabled: Pref = booleanPref("auto_backup_enabled", false) + + /** Export interval in minutes, floored at [SettingsPrefs.MIN_BACKUP_INTERVAL]. */ + val autoBackupIntervalMinutes: Pref = + longPref("auto_backup_interval_minutes", SettingsPrefs.DEFAULT_BACKUP_INTERVAL).coerced { + it.coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL) } - } - - // --- Automatic backup (issue #8) ------------------------------------ - - /** Whether periodic automatic `.ics` export of local calendars is on. Default OFF. */ - val autoBackupEnabled: Flow = store.data.map { it[AUTO_BACKUP_ENABLED_KEY] ?: false } - - suspend fun setAutoBackupEnabled(enabled: Boolean) { - store.edit { it[AUTO_BACKUP_ENABLED_KEY] = enabled } - } - - /** Export interval in minutes. Default daily; floored at [MIN_BACKUP_INTERVAL]. */ - val autoBackupIntervalMinutes: Flow = store.data.map { prefs -> - (prefs[AUTO_BACKUP_INTERVAL_KEY] ?: DEFAULT_BACKUP_INTERVAL).coerceAtLeast(MIN_BACKUP_INTERVAL) - } - - suspend fun setAutoBackupIntervalMinutes(minutes: Long) { - store.edit { it[AUTO_BACKUP_INTERVAL_KEY] = minutes.coerceAtLeast(MIN_BACKUP_INTERVAL) } - } /** Persisted SAF tree Uri of the destination folder, or null if unset. */ - val autoBackupFolderUri: Flow = store.data.map { it[AUTO_BACKUP_FOLDER_KEY] } + val autoBackupFolderUri: Pref = nullableStringPref("auto_backup_folder_uri") - suspend fun setAutoBackupFolderUri(uri: String?) { - store.edit { prefs -> - if (uri == null) prefs.remove(AUTO_BACKUP_FOLDER_KEY) else prefs[AUTO_BACKUP_FOLDER_KEY] = uri - } - } + /** The last automatic run's outcome, read together as [Preferences.autoBackupStatus]. */ + val autoBackupLastRun: Pref = longPref("auto_backup_last_run", 0L) + val autoBackupLastSuccess: Pref = booleanPref("auto_backup_last_success", true) + val autoBackupFailures: Pref = intPref("auto_backup_failures", 0) - /** Outcome of the last automatic run, for the settings status line. */ - val autoBackupStatus: Flow = store.data.map { prefs -> - BackupStatus( - lastRun = prefs[AUTO_BACKUP_LAST_RUN_KEY] ?: 0L, - lastSuccess = prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] ?: true, - consecutiveFailures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0, - ) - } - - /** Record an automatic run's outcome; resets the failure streak on success. */ - suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) { - store.edit { prefs -> - prefs[AUTO_BACKUP_LAST_RUN_KEY] = atMillis - prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] = success - val failures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0 - prefs[AUTO_BACKUP_FAILURES_KEY] = if (success) 0 else failures + 1 - } - } - - // --- Contact special dates (issue #15) ------------------------------ - - /** Master switch for the contact special-dates mirror. Default OFF (opt-in). */ - val specialDatesEnabled: Flow = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false } - - suspend fun setSpecialDatesEnabled(enabled: Boolean) { - store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled } - } + /** Master switch for the contact special-dates mirror (issue #15). Opt-in. */ + val specialDatesEnabled: Pref = booleanPref("special_dates_enabled", false) /** The date types the user wants mirrored (default: all three). */ - val specialDatesTypes: Flow> = store.data.map { prefs -> - SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true } - } - - suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) { - store.edit { it[typeEnabledKey(type)] = enabled } - } + val specialDateTypeEnabled: Map> = + SpecialDateType.entries.associateWith { booleanPref("special_dates_type_${it.name}", true) } /** The managed calendar id per type, for the ones that currently exist. */ - val specialDatesCalendars: Flow> = store.data.map { prefs -> - SpecialDateType.entries.mapNotNull { type -> - prefs[calendarIdKey(type)]?.let { type to it } - }.toMap() - } - - /** Every managed calendar id — the editor locks title/date/recurrence for their events. */ - val managedCalendarIds: Flow> = specialDatesCalendars.map { it.values.toSet() } - - suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) { - store.edit { prefs -> - if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId - } - } + val specialDatesCalendarId: Map> = + SpecialDateType.entries.associateWith { nullableLongPref("special_dates_calendar_${it.name}") } /** * The title template per type ("{name}'s birthday"); an empty string means * unset, so the settings screen can seed the localized default. `{name}` is * the contact name, `{age}` the age at the upcoming date (empty when the - * birth year is unknown or [specialDatesShowAge] is off). + * birth year is unknown or [specialDatesShowYear] is off). */ - val specialDatesTitleTemplates: Flow> = store.data.map { prefs -> - SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() } - } + val specialDatesTitleTemplate: Map> = + SpecialDateType.entries.associateWith { stringPref("special_dates_title_${it.name}", "") } - suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) { - store.edit { it[titleTemplateKey(type)] = template } - } + /** Whether `{year}` resolves in the title (only meaningful when the year is known). */ + val specialDatesShowYear: Pref = booleanPref("special_dates_show_year", true) - /** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */ - val specialDatesShowYear: Flow = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true } - - suspend fun setSpecialDatesShowYear(enabled: Boolean) { - store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled } - } - - /** Outcome of the last mirror sync, for the settings status line. */ - val specialDatesStatus: Flow = store.data.map { prefs -> - SpecialDatesStatus( - lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L, - stalled = prefs[SPECIAL_DATES_STALLED_KEY] - ?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } }, + /** The last mirror sync's outcome, read together as [Preferences.specialDatesStatus]. */ + val specialDatesLastRun: Pref = longPref("special_dates_last_run", 0L) + val specialDatesStalled: Pref = + nullableStringPref("special_dates_stalled_reason").map( + { stored -> stored?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } } }, + { it?.name }, ) - } - - /** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */ - suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) { - store.edit { prefs -> - prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis - if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name - } - } /** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */ - val specialDatesLastForegroundSync: Flow = - store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L } + val specialDatesLastForegroundSync: Pref = longPref("special_dates_last_foreground_sync", 0L) +} - suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) { - store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis } - } +val Preferences.timelineHours: TimelineHours + get() = TimelineHours.of( + enabled = this[SettingsKeys.timelineHoursEnabled], + startHour = this[SettingsKeys.timelineHoursStart], + endHour = this[SettingsKeys.timelineHoursEnd], + ) - private fun typeEnabledKey(type: SpecialDateType) = - booleanPreferencesKey("special_dates_type_${type.name}") +val Preferences.autoBackupStatus: BackupStatus + get() = BackupStatus( + lastRun = this[SettingsKeys.autoBackupLastRun], + lastSuccess = this[SettingsKeys.autoBackupLastSuccess], + consecutiveFailures = this[SettingsKeys.autoBackupFailures], + ) - private fun calendarIdKey(type: SpecialDateType) = - longPreferencesKey("special_dates_calendar_${type.name}") +val Preferences.specialDatesTypes: Set + get() = SettingsKeys.specialDateTypeEnabled.filterValues { this[it] }.keys - private fun titleTemplateKey(type: SpecialDateType) = - stringPreferencesKey("special_dates_title_${type.name}") +val Preferences.specialDatesCalendars: Map + get() = SettingsKeys.specialDatesCalendarId.mapNotNull { (type, pref) -> this[pref]?.let { type to it } }.toMap() - private fun parseRecentTimeZones(stored: String?): List = +val Preferences.specialDatesTitleTemplates: Map + get() = SettingsKeys.specialDatesTitleTemplate.mapValues { (_, pref) -> this[pref] } + +val Preferences.specialDatesStatus: SpecialDatesStatus + get() = SpecialDatesStatus( + lastRun = this[SettingsKeys.specialDatesLastRun], + stalled = this[SettingsKeys.specialDatesStalled], + ) + +val Preferences.onboardingProgress: OnboardingProgress + get() = OnboardingProgress( + answered = SettingsKeys.onboardingDone.filterValues { this[it] }.keys, + wizardArmed = this[SettingsKeys.onboardingWizardArmed], + ) + +/** The wizard's stored answers (#163); see [SettingsKeys.onboardingDone]. */ +data class OnboardingProgress( + val answered: Set, + val wizardArmed: Boolean, +) + +private fun PrefStore.snapshot(read: (Preferences) -> T): Flow = + data.map(read).distinctUntilChanged() + +/** Clamps on both read and write, so an out-of-range stored value degrades instead of leaking. */ +private fun Pref.coerced(clamp: (T) -> T): Pref = map(clamp, clamp) + +private fun agendaRangePref(name: String): Pref = + nullableStringPref(name).map({ parseAgendaRange(it, AgendaRange.Month) }, { it.storageValue() }) + +private fun reminderListPref(name: String): Pref> = + nullableStringPref(name).map({ it.toReminderList() }, { it.toStoredReminders() }) + +private fun reminderOverridePref(name: String): Pref>> = + nullableStringPref(name).map(reminderOverrideCodec::parse, reminderOverrideCodec::serialize) + +private suspend fun PrefStore.applyOverride( + pref: Pref>>, + calendarId: Long, + override: ReminderOverride, +) { + update(pref) { it.toMutableMap().apply { applyReminderOverride(calendarId, override) } } +} + +private fun parseRecentTimeZones(stored: String?): List = + stored?.split(',').orEmpty() + .map { it.trim() } + .filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess } + .distinct() + .take(SettingsPrefs.MAX_RECENT_TIME_ZONES) + +private fun parseFormFields(stored: String?): Set = when (stored) { + null -> SettingsPrefs.DEFAULT_FORM_FIELDS + else -> stored.split(',') + .mapNotNull { name -> EventFormField.entries.firstOrNull { it.name == name.trim() } } + .toSet() +} + +/** Parse a plain comma-joined view order, completed to every implemented view. */ +private fun parseViewOrder(stored: String?): List = + completeViewOrder( stored?.split(',').orEmpty() - .map { it.trim() } - .filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess } - .distinct() - .take(MAX_RECENT_TIME_ZONES) + .mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } }, + ) - private fun parseFormFields(stored: String?): Set = when (stored) { - null -> DEFAULT_FORM_FIELDS - else -> stored.split(',') - .mapNotNull { name -> EventFormField.entries.firstOrNull { it.name == name.trim() } } - .toSet() +/** Parse the quick-switch config; "!"-prefixed names are disabled. */ +private fun parseQuickSwitch(stored: String?): QuickSwitchConfig { + if (stored == null) return QuickSwitchConfig.Default + val parsed = stored.split(',').mapNotNull { raw -> + val token = raw.trim() + val disabled = token.startsWith("!") + val name = if (disabled) token.drop(1) else token + CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled } + } + val order = completeViewOrder(parsed.map { it.first }) + // Only views explicitly stored disabled are excluded; anything appended + // (a view added in a later release) defaults into the cycle. + val disabled = parsed.filter { it.second }.map { it.first }.toSet() + return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet()) +} + +private fun serializeQuickSwitch(config: QuickSwitchConfig): String = + completeViewOrder(config.order).joinToString(",") { view -> + if (view in config.enabled) view.name else "!${view.name}" } - /** Parse a plain comma-joined view order, completed to every implemented view. */ - private fun parseViewOrder(stored: String?): List = - completeViewOrder( - stored?.split(',').orEmpty() - .mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } }, - ) - - /** Parse the quick-switch config; "!"-prefixed names are disabled. */ - private fun parseQuickSwitch(stored: String?): QuickSwitchConfig { - if (stored == null) return QuickSwitchConfig.Default - val parsed = stored.split(',').mapNotNull { raw -> - val token = raw.trim() - val disabled = token.startsWith("!") - val name = if (disabled) token.drop(1) else token - CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled } - } - val order = completeViewOrder(parsed.map { it.first }) - // Only views explicitly stored disabled are excluded; anything appended - // (a view added in a later release) defaults into the cycle. - val disabled = parsed.filter { it.second }.map { it.first }.toSet() - return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet()) - } - - private fun serializeQuickSwitch(config: QuickSwitchConfig): String = - completeViewOrder(config.order).joinToString(",") { view -> - if (view in config.enabled) view.name else "!${view.name}" - } - - /** Keep stored order; insert the new multi-day view after Week on upgrades. */ - private fun completeViewOrder(seen: List): List { - val ordered = seen.distinct().toMutableList() - IMPLEMENTED_VIEWS.forEach { view -> - if (view !in ordered) { - val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) { - ordered.indexOf(CalendarView.Week) + 1 - } else { - ordered.size - } - ordered.add(index, view) +/** Keep stored order; insert the new multi-day view after Week on upgrades. */ +private fun completeViewOrder(seen: List): List { + val ordered = seen.distinct().toMutableList() + IMPLEMENTED_VIEWS.forEach { view -> + if (view !in ordered) { + val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) { + ordered.indexOf(CalendarView.Week) + 1 + } else { + ordered.size } + ordered.add(index, view) } - return ordered - } - - companion object { - internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") - internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") - internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors") - internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font") - internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font") - internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp") - internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp") - internal val WEEK_START_KEY = stringPreferencesKey("week_start") - internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") - internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") - internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar") - internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size") - internal val AGENDA_SHOW_TODAY_KEY = - booleanPreferencesKey("agenda_show_today") - internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") - internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") - internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") - internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") - internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") - internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style") - internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale") - internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar") - internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule") - internal val TAP_OPENS_EDITOR_KEY = booleanPreferencesKey("tap_opens_editor") - internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") - internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") - internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order") - internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") - internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title") - internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes") - internal val CALENDAR_EVENT_DURATION_KEY = - stringPreferencesKey("per_calendar_event_duration") - - /** A new timed event's length until the user changes it: one hour. */ - const val DEFAULT_EVENT_DURATION = 60 - internal const val MIN_EVENT_DURATION = 1 - /** A day — past that the form is describing a multi-day event, not a default. */ - internal const val MAX_EVENT_DURATION = 1_440 - internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") - internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done") - internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed") - internal val ONBOARDING_CALENDARS_KEY = - booleanPreferencesKey("onboarding_calendars_done") - internal val ONBOARDING_HIDDEN_KEY = - booleanPreferencesKey("onboarding_hidden_calendars_done") - internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done") - internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done") - internal val ONBOARDING_MONTH_STYLE_KEY = - booleanPreferencesKey("onboarding_month_style_done") - internal val ONBOARDING_VISIBILITY_KEY = - booleanPreferencesKey("onboarding_visibility_done") - internal val ONBOARDING_DONE_KEY = booleanPreferencesKey("onboarding_done_shown") - internal val ALLOW_COLOR_UNSUPPORTED_KEY = - booleanPreferencesKey("allow_color_unsupported_calendars") - internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes") - internal val DEFAULT_ALLDAY_REMINDER_KEY = - stringPreferencesKey("default_allday_reminder_minutes") - internal val ALLDAY_REMINDER_TIME_KEY = - intPreferencesKey("allday_reminder_time_minutes") - /** 09:00 as minutes from midnight; the default all-day reminder fire time. */ - internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540 - private const val MINUTES_PER_DAY = 1_440 - internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes") - internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count") - internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled") - internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start") - internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end") - /** Default snooze delay for the notification "Snooze" action. */ - const val DEFAULT_SNOOZE_MINUTES = 10 - const val DEFAULT_MULTI_DAY_COUNT = 3 - val MULTI_DAY_COUNTS = 3..6 - internal val CALENDAR_REMINDER_OVERRIDE_KEY = - stringPreferencesKey("per_calendar_reminder_override") - internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY = - stringPreferencesKey("per_calendar_allday_reminder_override") - internal val DEFAULT_FORM_FIELDS = - setOf(EventFormField.Location, EventFormField.Description) - internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones") - - /** Enough to cover the zones a user actually recurs to, without a wall of rows. */ - internal const val MAX_RECENT_TIME_ZONES = 5 - internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled") - internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes") - internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri") - internal val AUTO_BACKUP_LAST_RUN_KEY = longPreferencesKey("auto_backup_last_run") - internal val AUTO_BACKUP_LAST_SUCCESS_KEY = booleanPreferencesKey("auto_backup_last_success") - internal val AUTO_BACKUP_FAILURES_KEY = intPreferencesKey("auto_backup_failures") - /** Default automatic-backup interval: daily. */ - const val DEFAULT_BACKUP_INTERVAL = 1_440L - /** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */ - const val MIN_BACKUP_INTERVAL = 30L - - internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled") - internal val SPECIAL_DATES_SHOW_YEAR_KEY = booleanPreferencesKey("special_dates_show_year") - internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run") - internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason") - internal val SPECIAL_DATES_LAST_FG_SYNC_KEY = - longPreferencesKey("special_dates_last_foreground_sync") } + return ordered } /** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */ @@ -1231,6 +978,3 @@ private fun Map.toStoredDurations(): String = "$id$KEY_VALUE_SEP$minutes" } - -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/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt index 58286601..9407aa9b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt @@ -166,15 +166,6 @@ class OnboardingViewModel @Inject constructor( hiddenTouched, ) { hidden, touched -> if (touched) null else hidden } - /** Both calendar steps' inputs: what each applies to, and whether it is answered. */ - private val calendarsFlags: Flow = combine( - calendarsApplies, - prefs.onboardingCalendarsDone, - hiddenApplies, - prefs.onboardingHiddenCalendarsDone, - ::CalendarsFlags, - ) - /** * The calendars the hidden-calendars step lists, so the user can judge which * to switch on rather than take the app's word that there are some. Live: a @@ -188,47 +179,35 @@ class OnboardingViewModel @Inject constructor( initialValue = emptyList(), ) - private val flags: Flow = combine( - prefs.reminderOnboardingDone, - prefs.onboardingWizardArmed, - prefs.onboardingBackupDone, - prefs.onboardingViewDone, - prefs.onboardingMonthStyleDone, - ::OnboardingFlags, - ) - - /** The closing steps' own flags, and whether the notice applies at all. */ - private val closingFlags: Flow = combine( - calendarPrefs.visibilityNoticePending.map { it == true }, - prefs.onboardingVisibilityDone, - prefs.onboardingDoneShown, - ::ClosingFlags, - ) + /** What the two calendar steps apply to (#287). */ + private val calendarStepsApply: Flow> = + combine(calendarsApplies, hiddenApplies, ::Pair) val plan: StateFlow = combine( hasPermission, - flags, - closingFlags, + prefs.onboardingProgress, + calendarPrefs.visibilityNoticePending.map { it == true }, backupApplies, - calendarsFlags, - ) { granted, stored, closing, backup, calendars -> + calendarStepsApply, + ) { granted, progress, visibilityArmed, backup, (calendars, hidden) -> + val answered = progress.answered granted?.let { onboardingPlan( hasPermission = it, - remindersDone = stored.remindersDone, - wizardArmed = stored.wizardArmed, - backupDone = stored.backupDone, - viewDone = stored.viewDone, - monthStyleDone = stored.monthStyleDone, + remindersDone = OnboardingStep.Reminders in answered, + wizardArmed = progress.wizardArmed, + backupDone = OnboardingStep.Backup in answered, + viewDone = OnboardingStep.View in answered, + monthStyleDone = OnboardingStep.MonthStyle in answered, backupApplies = backup, - hiddenDone = calendars.hiddenDone, - hiddenApplies = calendars.hiddenApplies, - calendarsDone = calendars.done, - calendarsApplies = calendars.applies, - visibilityArmed = closing.visibilityArmed, - visibilityDone = closing.visibilityDone, - doneShown = closing.doneShown, + hiddenDone = OnboardingStep.HiddenCalendars in answered, + hiddenApplies = hidden, + calendarsDone = OnboardingStep.Calendars in answered, + calendarsApplies = calendars, + visibilityArmed = visibilityArmed, + visibilityDone = OnboardingStep.Visibility in answered, + doneShown = OnboardingStep.Done in answered, ) } }.stateWhileSubscribed( @@ -258,7 +237,7 @@ class OnboardingViewModel @Inject constructor( * to a restarted process, leaving them the reminder step alone. */ private suspend fun armWizardIfFresh() { - if (!prefs.reminderOnboardingDone.first()) prefs.armOnboardingWizard() + prefs.armOnboardingWizard() } /** @@ -274,7 +253,7 @@ class OnboardingViewModel @Inject constructor( fun finishReminders(remindersEnabled: Boolean) { viewModelScope.launch { prefs.setRemindersEnabled(remindersEnabled) - prefs.setReminderOnboardingDone() + prefs.setOnboardingDone(OnboardingStep.Reminders) // Nothing else re-arms the scan: turning reminders off cancels the // alarm (#75). scanner.scan() @@ -288,24 +267,13 @@ class OnboardingViewModel @Inject constructor( * returned to, which [OnboardingPlan.canGoBack] already refuses. */ fun goBack() { - viewModelScope.launch { - when (plan.value?.previous) { - OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false) - OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false) - OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false) - OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false) - OnboardingStep.View -> prefs.setOnboardingViewDone(false) - OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false) - OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false) - OnboardingStep.Done -> prefs.setOnboardingDoneShown(false) - OnboardingStep.Permission, null -> Unit - } - } + val previous = plan.value?.previous ?: return + viewModelScope.launch { prefs.setOnboardingDone(previous, done = false) } } /** Close the backup step without setting anything up. */ fun skipBackup() { - viewModelScope.launch { prefs.setOnboardingBackupDone() } + viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.Backup) } } /** @@ -337,7 +305,7 @@ class OnboardingViewModel @Inject constructor( /** Close the calendar step once its confirmation has been read. */ fun finishCalendars() { viewModelScope.launch { - prefs.setOnboardingCalendarsDone() + prefs.setOnboardingDone(OnboardingStep.Calendars) _creation.value = null } } @@ -352,7 +320,7 @@ class OnboardingViewModel @Inject constructor( * choice, and so is going off to set up an account and coming back. */ fun skipCalendars() { - viewModelScope.launch { prefs.setOnboardingCalendarsDone() } + viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.Calendars) } } /** @@ -378,7 +346,7 @@ class OnboardingViewModel @Inject constructor( ) // Immediate feedback: the first backup lands while onboarding runs. BackupScheduler.runNow(context) - prefs.setOnboardingBackupDone() + prefs.setOnboardingDone(OnboardingStep.Backup) } } @@ -392,11 +360,11 @@ class OnboardingViewModel @Inject constructor( /** Close a picker step — the choice itself is saved as it is made. */ fun finishView() { - viewModelScope.launch { prefs.setOnboardingViewDone() } + viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.View) } } fun finishMonthStyle() { - viewModelScope.launch { prefs.setOnboardingMonthStyleDone() } + viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.MonthStyle) } } /** Switch one listed calendar on or off, from the hidden-calendars step (#287). */ @@ -433,7 +401,7 @@ class OnboardingViewModel @Inject constructor( /** Close the hidden-calendars step, switched on or left as they were (#287). */ fun finishHiddenCalendars() { viewModelScope.launch { - prefs.setOnboardingHiddenCalendarsDone() + prefs.setOnboardingDone(OnboardingStep.HiddenCalendars) // The error belongs to the attempt, not to the step: stepping back // into it later must not find a refusal nothing has asked for yet. _hiddenWriteFailed.value = false @@ -448,7 +416,7 @@ class OnboardingViewModel @Inject constructor( /** Acknowledge the calendar-visibility notice (#75). */ fun finishVisibility() { viewModelScope.launch { - prefs.setOnboardingVisibilityDone() + prefs.setOnboardingDone(OnboardingStep.Visibility) // Retiring the notice drops its step, so it waits until nothing // follows — the count must not fall on the way to a later screen. if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice() @@ -485,36 +453,12 @@ class OnboardingViewModel @Inject constructor( } } -/** The stored answers the plan is derived from. */ -private data class OnboardingFlags( - val remindersDone: Boolean, - val wizardArmed: Boolean, - val backupDone: Boolean, - val viewDone: Boolean, - val monthStyleDone: Boolean, -) - /** How the calendar step's create attempt ended (#287). */ sealed interface CalendarCreation { data class Created(val name: String) : CalendarCreation data object Failed : CalendarCreation } -/** Both calendar steps' inputs (#287). */ -private data class CalendarsFlags( - val applies: Boolean?, - val done: Boolean, - val hiddenApplies: Boolean?, - val hiddenDone: Boolean, -) - -/** The tail of the flow: the visibility notice and the closing screen. */ -private data class ClosingFlags( - val visibilityArmed: Boolean, - val visibilityDone: Boolean, - val doneShown: Boolean, -) - /** What the view step starts from, and renders its previews with. */ data class ViewChoice( val defaultView: CalendarView, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt index 83dd8151..a7618033 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt @@ -49,7 +49,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.appname.LauncherName -import de.jeanlucmakiola.calendula.data.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.calendula.domain.FontRole import de.jeanlucmakiola.calendula.ui.theme.BundledFont import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 0af6fd7a..f1c1160c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -3,7 +3,7 @@ package de.jeanlucmakiola.calendula.ui.settings import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs -import de.jeanlucmakiola.calendula.data.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.CalendarSource 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 e4c47f3b..c336a0f5 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 @@ -25,7 +25,7 @@ import de.jeanlucmakiola.floret.reminders.reminderOverrideFor import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason -import de.jeanlucmakiola.calendula.data.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt index ef3c603b..f1ed7640 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.annotation.ColorRes import androidx.core.content.edit import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.data.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.ThemeMode /** * A synchronous copy of the theme choice, for the window backdrop drawn before diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt index 1d99bc74..9f0fb46b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefsTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.prefs +import androidx.datastore.preferences.core.stringPreferencesKey import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.testDataStore import de.jeanlucmakiola.calendula.testPrefs @@ -39,7 +40,7 @@ class CalendarPrefsTest { val prefs = CalendarPrefs(store) store.updateData { p -> val mutable = p.toMutablePreferences() - mutable[CalendarPrefs.HIDDEN_IDS_KEY] = "1,abc,3" + mutable[stringPreferencesKey("hidden_calendar_ids")] = "1,abc,3" mutable } assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L)) @@ -54,7 +55,7 @@ class CalendarPrefsTest { val store = testDataStore(tempDir, "test_prefs") val prefs = CalendarPrefs(store) store.updateData { p -> - p.toMutablePreferences().apply { this[CalendarPrefs.DISABLED_IDS_KEY] = "2,9" } + p.toMutablePreferences().apply { this[stringPreferencesKey("disabled_calendar_ids")] = "2,9" } } assertThat(prefs.pendingDisabledCalendarIds.first()).isEqualTo(setOf(2L, 9L)) } 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 ce73b983..44101d11 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 @@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.testSettings import de.jeanlucmakiola.floret.reminders.ReminderOverride import androidx.compose.ui.unit.dp +import androidx.datastore.preferences.core.stringPreferencesKey import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.FontRole @@ -16,6 +17,8 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle +import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep +import de.jeanlucmakiola.floret.prefs.ThemeMode import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -68,7 +71,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(store) store.updateData { p -> val m = p.toMutablePreferences() - m[SettingsPrefs.WEEK_START_KEY] = "MONDAY" + m[stringPreferencesKey("week_start")] = "MONDAY" m } assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.MONDAY)) @@ -80,7 +83,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(store) store.updateData { p -> val m = p.toMutablePreferences() - m[SettingsPrefs.WEEK_START_KEY] = "FUNDAY" + m[stringPreferencesKey("week_start")] = "FUNDAY" m } assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto) @@ -191,7 +194,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(store) store.updateData { p -> val m = p.toMutablePreferences() - m[SettingsPrefs.MONTH_VIEW_STYLE_KEY] = "Carousel" + m[stringPreferencesKey("month_view_style")] = "Carousel" m } assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged) @@ -203,7 +206,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(store) store.updateData { p -> val m = p.toMutablePreferences() - m[SettingsPrefs.THEME_MODE_KEY] = "PLAID" + m[stringPreferencesKey("theme_mode")] = "PLAID" m } assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM) @@ -269,7 +272,7 @@ class SettingsPrefsTest { // Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent. store.updateData { p -> val m = p.toMutablePreferences() - m[SettingsPrefs.QUICK_SWITCH_VIEWS_KEY] = "!Day,Week,Hologram" + m[stringPreferencesKey("quick_switch_views")] = "!Day,Week,Hologram" m } val config = prefs.quickSwitchConfig.first() @@ -322,7 +325,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(store) store.updateData { p -> val m = p.toMutablePreferences() - m[SettingsPrefs.DRAWER_VIEW_ORDER_KEY] = "Agenda,Nope,Week" + m[stringPreferencesKey("drawer_view_order")] = "Agenda,Nope,Week" m } assertThat(prefs.drawerViewOrder.first()).containsExactly( @@ -337,7 +340,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(store) store.updateData { p -> val m = p.toMutablePreferences() - m[SettingsPrefs.FORM_FIELDS_KEY] = "Location,Hologram" + m[stringPreferencesKey("event_form_default_fields")] = "Location,Hologram" m } assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location) @@ -347,7 +350,7 @@ class SettingsPrefsTest { fun `reminders default to enabled, onboarding to not done`(@TempDir tempDir: Path) = runTest { val prefs = testSettings(tempDir) assertThat(prefs.remindersEnabled.first()).isTrue() - assertThat(prefs.reminderOnboardingDone.first()).isFalse() + assertThat(prefs.onboardingProgress.first().answered).isEmpty() } @Test @@ -360,24 +363,35 @@ class SettingsPrefsTest { @Test fun `reminder onboarding completes one-way`(@TempDir tempDir: Path) = runTest { val prefs = testSettings(tempDir) - prefs.setReminderOnboardingDone() - assertThat(prefs.reminderOnboardingDone.first()).isTrue() + prefs.setOnboardingDone(OnboardingStep.Reminders) + assertThat(prefs.onboardingProgress.first().answered).containsExactly(OnboardingStep.Reminders) + } + + @Test + fun `a step's answer can be cleared again`(@TempDir tempDir: Path) = runTest { + val prefs = testSettings(tempDir) + prefs.setOnboardingDone(OnboardingStep.Backup) + prefs.setOnboardingDone(OnboardingStep.View) + + prefs.setOnboardingDone(OnboardingStep.Backup, done = false) + + assertThat(prefs.onboardingProgress.first().answered).containsExactly(OnboardingStep.View) } @Test fun `the wizard arms on a first run`(@TempDir tempDir: Path) = runTest { val prefs = testSettings(tempDir) prefs.armOnboardingWizard() - assertThat(prefs.onboardingWizardArmed.first()).isTrue() + assertThat(prefs.onboardingProgress.first().wizardArmed).isTrue() } @Test fun `the wizard does not arm once the reminder step is answered`(@TempDir tempDir: Path) = runTest { // An existing install re-granting the permission is not re-onboarded. val prefs = testSettings(tempDir) - prefs.setReminderOnboardingDone() + prefs.setOnboardingDone(OnboardingStep.Reminders) prefs.armOnboardingWizard() - assertThat(prefs.onboardingWizardArmed.first()).isFalse() + assertThat(prefs.onboardingProgress.first().wizardArmed).isFalse() } @Test @@ -386,8 +400,8 @@ class SettingsPrefsTest { prefs.armOnboardingWizard() prefs.finishOnboardingWizard() - assertThat(prefs.onboardingDoneShown.first()).isTrue() - assertThat(prefs.onboardingWizardArmed.first()).isFalse() + assertThat(prefs.onboardingProgress.first().answered).containsExactly(OnboardingStep.Done) + assertThat(prefs.onboardingProgress.first().wizardArmed).isFalse() } @Test @@ -415,7 +429,7 @@ class SettingsPrefsTest { // The pre-multi-reminder format stored a bare integer string. store.updateData { p -> val m = p.toMutablePreferences() - m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30" + m[stringPreferencesKey("default_reminder_minutes")] = "30" m } assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30) @@ -427,7 +441,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(store) store.updateData { p -> val m = p.toMutablePreferences() - m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon" + m[stringPreferencesKey("default_reminder_minutes")] = "soon" m } assertThat(prefs.defaultReminderMinutes.first()).isEmpty() @@ -464,7 +478,7 @@ class SettingsPrefsTest { // reminders. Partially-valid values keep their salvageable minutes. store.updateData { p -> val m = p.toMutablePreferences() - m[SettingsPrefs.CALENDAR_REMINDER_OVERRIDE_KEY] = "5=none;7=abc;8=;9=30,abc;11=15" + m[stringPreferencesKey("per_calendar_reminder_override")] = "5=none;7=abc;8=;9=30,abc;11=15" m } assertThat(prefs.perCalendarReminderOverride.first()).containsExactly( @@ -591,7 +605,7 @@ class SettingsPrefsTest { store.updateData { p -> val m = p.toMutablePreferences() // A bad id, a non-numeric length, one out of range, and a good entry. - m[SettingsPrefs.CALENDAR_EVENT_DURATION_KEY] = "x=60;7=soon;8=99999;9=45" + m[stringPreferencesKey("per_calendar_event_duration")] = "x=60;7=soon;8=99999;9=45" m } assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 45)