Move settings storage onto floret's PrefStore (#415)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped

Step 5 of the simplification review (preferences pipeline). Needs floret-kit#15 merged first; the submodule pin (3b376ac) is on that PR's branch, not on floret main yet.

- **SettingsPrefs, CalendarPrefs, ReminderStatePrefs** read and write through floret core-prefs (new dependency). Every setting is one `Pref` in a new `SettingsKeys` object, so each flow and setter is a single line. Stored key names, defaults and clamping are unchanged; the old and new key lists diff clean. Flows are now distinct-until-changed, so an unrelated key's write no longer re-emits them.
- **ThemeMode** is floret's (same values, same `theme_mode`/`dynamic_color` keys). MainActivity uses floret's `isDark`/`isPureBlack` instead of its two hand-written `when`s.
- **Onboarding flags**: the eight per-step flow/setter pairs are one `OnboardingStep` → pref map, with `onboardingProgress` and `setOnboardingDone(step, done)`. Same key names. OnboardingViewModel loses its three carrier classes and the back-navigation `when`.
- **SettingsViewModel** builds `SettingsUiState` from one prefs snapshot instead of the four-level nested `combine` and its seven carrier classes; the font and special-dates states do the same. The 30 one-line setters use a `save {}` helper. Setters with side effects (widgets, scan, scheduler) keep their bodies; the widget refresh code is untouched.

Net about −550 lines. Unit tests and lint pass; the prefs tests now seed raw on-disk key names instead of the removed internal key constants.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/415
This commit is contained in:
Jean-Luc Makiola
2026-10-16 13:17:49 +02:00
co-authored by makiolaj
parent b627ed04b7
commit bb8c91acc5
13 changed files with 874 additions and 1425 deletions
+1
View File
@@ -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")
@@ -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()) {
@@ -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<Preferences>,
store: DataStore<Preferences>,
) {
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<Set<Long>> = store.data
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
.distinctUntilChanged()
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(HIDDEN_IDS)
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
}
suspend fun setHiddenCalendarIds(ids: Set<Long>) = 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<Set<Long>> = store.data
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
.distinctUntilChanged()
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(DISABLED_IDS)
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
editPendingDisabled { it + ids }
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
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<Long>) =
editPendingDisabled { it - ids.toSet() }
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
store.edit { prefs ->
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
}
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) {
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<Boolean?> = store.data.map { prefs ->
prefs[VISIBILITY_NOTICE_KEY]
}
val visibilityNoticePending: Flow<Boolean?> = 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<Long?> = store.data.map { prefs ->
prefs[LAST_USED_CALENDAR_KEY]
}
val lastUsedCalendarId: Flow<Long?> = 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<Long> = orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
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<Set<Long>> = nullableStringPref(name).map(
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
)
@@ -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<Preferences>,
store: DataStore<Preferences>,
) {
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")
}
}
File diff suppressed because it is too large Load Diff
@@ -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<CalendarsFlags> = 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<OnboardingFlags> = 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<ClosingFlags> = combine(
calendarPrefs.visibilityNoticePending.map { it == true },
prefs.onboardingVisibilityDone,
prefs.onboardingDoneShown,
::ClosingFlags,
)
/** What the two calendar steps apply to (#287). */
private val calendarStepsApply: Flow<Pair<Boolean?, Boolean?>> =
combine(calendarsApplies, hiddenApplies, ::Pair)
val plan: StateFlow<OnboardingPlan?> =
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,
@@ -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
@@ -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
@@ -116,7 +116,7 @@ data class SettingsUiState(
/**
* State for the contact special-dates sub-page (issue #15). Exposed as its own
* flow rather than folded into [SettingsUiState] (whose combine is already full).
* flow rather than folded into [SettingsUiState].
* [titleTemplates] is always populated — a blank stored template resolves to the
* localized default so the editor never shows an empty field.
*/
@@ -6,6 +6,7 @@ import android.os.Build
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -23,9 +24,16 @@ import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.specialDatesCalendars
import de.jeanlucmakiola.calendula.data.prefs.specialDatesStatus
import de.jeanlucmakiola.calendula.data.prefs.specialDatesTitleTemplates
import de.jeanlucmakiola.calendula.data.prefs.specialDatesTypes
import de.jeanlucmakiola.calendula.data.prefs.timelineHours
import de.jeanlucmakiola.floret.prefs.get
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
@@ -36,7 +44,6 @@ import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
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
@@ -89,163 +96,66 @@ class SettingsViewModel @Inject constructor(
.catch { emit(emptyList()) }
val state: StateFlow<SettingsUiState> =
combine(
// combine() types up to five flows, so the prefs split into two
// groups that fold together in the outer combine.
combine(
prefs.themeMode,
prefs.dynamicColor,
prefs.weekStart,
prefs.defaultFormFields,
prefs.remindersEnabled,
) { theme, dynamic, weekStart, formFields, reminders ->
SettingsUiState(
themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart,
defaultFormFields = formFields,
remindersEnabled = reminders,
)
},
combine(
prefs.allowColorOnUnsupportedCalendars,
prefs.defaultReminderMinutes,
prefs.defaultAllDayReminderMinutes,
prefs.allDayReminderTimeMinutes,
prefs.snoozeMinutes,
) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze ->
ReminderDefaults(
allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze,
)
},
combine(
prefs.perCalendarReminderOverride,
prefs.perCalendarAllDayReminderOverride,
writableCalendars,
prefs.managedCalendarIds,
// The new-event duration defaults (#54) fold into one flow so they
// fit this group — the outer combine is at its five-arg limit.
combine(
prefs.defaultEventDurationMinutes,
prefs.perCalendarEventDuration,
) { duration, perCalendar -> EventDurations(duration, perCalendar) },
) { overrides, allDayOverrides, calendars, managedIds, durations ->
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds, durations)
},
combine(
prefs.defaultView,
prefs.agendaScreenRange,
prefs.agendaWidgetRange,
prefs.timeFormat,
// Display toggles folded into one flow so they fit this group —
// the outer combine is already at its five-arg limit.
combine(
prefs.showHourGrid,
prefs.showWeekNumbers,
prefs.agendaShowToday,
prefs.softenCalendarColors,
combine(
prefs.todayButtonInToolbar,
combine(prefs.dragToReschedule, prefs.tapOpensEditor, ::Pair),
prefs.multiDayCount,
::Triple,
),
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, gestures, multiDay) ->
DisplayToggles(
hourLines, weekNumbers, showToday, soften, todayInToolbar,
dragToReschedule = gestures.first,
tapOpensEditor = gestures.second,
multiDayCount = multiDay,
)
},
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourGrid = toggles.showHourGrid,
showWeekNumbers = toggles.showWeekNumbers,
agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors,
todayButtonInToolbar = toggles.todayButtonInToolbar,
dragToReschedule = toggles.dragToReschedule,
tapOpensEditor = toggles.tapOpensEditor,
multiDayCount = toggles.multiDayCount,
)
},
combine(
prefs.agendaShowRangeBar,
prefs.autofocusEventTitle,
prefs.pastEventDisplay,
prefs.dimCompletedEvents,
// View customisation (#24) folded into one flow so it fits this
// group — the outer combine is already at its five-arg limit.
combine(
prefs.quickSwitchConfig,
prefs.drawerViewOrder,
prefs.monthViewStyle,
prefs.widgetSize,
combine(prefs.timelineScale, prefs.timelineHours, ::Pair),
) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) ->
ViewCustomization(
quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours,
)
},
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
},
) { base, defaults, overrides, views, misc ->
base.copy(
defaultView = views.defaultView,
agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourGrid = views.showHourGrid,
showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors,
todayButtonInToolbar = views.todayButtonInToolbar,
dragToReschedule = views.dragToReschedule,
tapOpensEditor = views.tapOpensEditor,
multiDayCount = views.multiDayCount,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
dimCompletedEvents = misc.dimCompletedEvents,
quickSwitchConfig = misc.viewCustomization.quickSwitch,
drawerViewOrder = misc.viewCustomization.drawerOrder,
monthViewStyle = misc.viewCustomization.monthViewStyle,
widgetSize = misc.viewCustomization.widgetSize,
timelineScale = misc.viewCustomization.timelineScale,
timelineHours = misc.viewCustomization.timelineHours,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
allDayReminderTimeMinutes = defaults.allDayReminderTime,
snoozeMinutes = defaults.snoozeMinutes,
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
managedCalendarIds = overrides.managedIds,
defaultEventDurationMinutes = overrides.durations.default,
perCalendarEventDuration = overrides.durations.perCalendar,
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
}.stateWhileSubscribed(
scope = viewModelScope,
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
private fun Preferences.toUiState(calendars: List<CalendarSource>) = SettingsUiState(
themeMode = this[SettingsKeys.themeMode],
dynamicColor = this[SettingsKeys.dynamicColor] && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
softenColors = this[SettingsKeys.softenCalendarColors],
weekStart = this[SettingsKeys.weekStart],
timeFormat = this[SettingsKeys.timeFormat],
showHourGrid = this[SettingsKeys.showHourGrid],
multiDayCount = this[SettingsKeys.multiDayCount],
pastEventDisplay = this[SettingsKeys.pastEventDisplay],
dimCompletedEvents = this[SettingsKeys.dimCompletedEvents],
showWeekNumbers = this[SettingsKeys.showWeekNumbers],
todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar],
dragToReschedule = this[SettingsKeys.dragToReschedule],
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
agendaWidgetRange = this[SettingsKeys.agendaWidgetRange],
agendaShowToday = this[SettingsKeys.agendaShowToday],
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
widgetSize = this[SettingsKeys.widgetSize],
defaultView = this[SettingsKeys.defaultView],
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
monthViewStyle = this[SettingsKeys.monthViewStyle],
timelineScale = this[SettingsKeys.timelineScale],
timelineHours = timelineHours,
drawerViewOrder = this[SettingsKeys.drawerViewOrder],
defaultFormFields = this[SettingsKeys.defaultFormFields],
autofocusEventTitle = this[SettingsKeys.autofocusEventTitle],
defaultEventDurationMinutes = this[SettingsKeys.defaultEventDurationMinutes],
perCalendarEventDuration = this[SettingsKeys.perCalendarEventDuration],
remindersEnabled = this[SettingsKeys.remindersEnabled],
defaultReminderMinutes = this[SettingsKeys.defaultReminderMinutes],
defaultAllDayReminderMinutes = this[SettingsKeys.defaultAllDayReminderMinutes],
allDayReminderTimeMinutes = this[SettingsKeys.allDayReminderTimeMinutes],
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
writableCalendars = calendars,
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
managedCalendarIds = specialDatesCalendars.values.toSet(),
)
/**
* The app-wide custom-font choice (issue #19). Its own flow — both this
* screen and [MainActivity] (which builds the typography) read it
* independently of the main settings combine.
* independently of the main settings state.
*/
val fontState: StateFlow<AppFontSettings> = combine(
prefs.brandFont,
prefs.plainFont,
prefs.brandFontStamp,
prefs.plainFontStamp,
) { brand, plain, brandStamp, plainStamp ->
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
val fontState: StateFlow<AppFontSettings> = prefs.data.map {
AppFontSettings(
brand = it[SettingsKeys.brandFont],
plain = it[SettingsKeys.plainFont],
brandStamp = it[SettingsKeys.brandFontStamp],
plainStamp = it[SettingsKeys.plainFontStamp],
)
}.stateWhileSubscribed(
scope = viewModelScope,
initialValue = AppFontSettings(),
@@ -275,100 +185,26 @@ class SettingsViewModel @Inject constructor(
// stale read and can leave the widget on an intermediate value.
private val widgetRefreshMutex = Mutex()
private data class ReminderDefaults(
val allowColor: Boolean,
val defaultReminder: List<Int>,
val allDayReminder: List<Int>,
val allDayReminderTime: Int,
val snoozeMinutes: Int,
)
private data class ReminderOverrides(
val timed: Map<Long, List<Int>>,
val allDay: Map<Long, List<Int>>,
val calendars: List<CalendarSource>,
val managedIds: Set<Long>,
val durations: EventDurations,
)
private data class EventDurations(
val default: Int,
val perCalendar: Map<Long, Int>,
)
private data class ViewSettings(
val defaultView: CalendarView,
val agendaScreenRange: AgendaRange,
val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref,
val showHourGrid: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
val tapOpensEditor: Boolean,
val multiDayCount: Int,
)
private data class DisplayToggles(
val showHourGrid: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
val tapOpensEditor: Boolean,
val multiDayCount: Int,
)
private data class MiscSettings(
val showRangeBar: Boolean,
val autofocusEventTitle: Boolean,
val pastEventDisplay: PastEventDisplay,
val dimCompletedEvents: Boolean,
val viewCustomization: ViewCustomization,
)
private data class ViewCustomization(
val quickSwitch: QuickSwitchConfig,
val drawerOrder: List<CalendarView>,
val monthViewStyle: MonthViewStyle,
val widgetSize: WidgetSize,
val timelineScale: TimelineScale,
val timelineHours: TimelineHours,
)
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
combine(
prefs.specialDatesEnabled,
prefs.specialDatesTypes,
prefs.specialDatesTitleTemplates,
prefs.specialDatesShowYear,
prefs.specialDatesStatus,
) { enabled, types, templates, showYear, status ->
SpecialDatesUiState(
enabled = enabled,
types = types,
// A blank stored template resolves to the localized default so the
// editor always shows something sensible.
titleTemplates = SpecialDateType.entries.associateWith { type ->
specialDatesSpec.resolveTitleTemplate(type, templates)
},
showYear = showYear,
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
lastRun = status.lastRun,
)
},
prefs.specialDatesCalendars,
prefs.perCalendarAllDayReminderOverride,
) { base, calendars, allDayOverrides ->
base.copy(
/** Contact special-dates sub-page (issue #15). */
val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored ->
val templates = stored.specialDatesTitleTemplates
val allDayOverrides = stored[SettingsKeys.perCalendarAllDayReminderOverride]
val status = stored.specialDatesStatus
SpecialDatesUiState(
enabled = stored[SettingsKeys.specialDatesEnabled],
types = stored.specialDatesTypes,
// A blank stored template resolves to the localized default so the
// editor always shows something sensible.
titleTemplates = SpecialDateType.entries.associateWith { type ->
specialDatesSpec.resolveTitleTemplate(type, templates)
},
// An override is always seeded on creation; present-empty = None.
reminderChoices = calendars.mapValues { (_, calendarId) ->
reminderChoices = stored.specialDatesCalendars.mapValues { (_, calendarId) ->
allDayOverrides.reminderOverrideFor(calendarId)
},
showYear = stored[SettingsKeys.specialDatesShowYear],
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
lastRun = status.lastRun,
)
}.stateWhileSubscribed(
scope = viewModelScope,
@@ -427,13 +263,9 @@ class SettingsViewModel @Inject constructor(
/** Kick off an immediate reconcile (the "Sync now" action). */
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}
fun setThemeMode(mode: ThemeMode) = save { setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) {
viewModelScope.launch { prefs.setDynamicColor(enabled) }
}
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
fun setSoftenColors(enabled: Boolean) {
viewModelScope.launch {
@@ -446,12 +278,10 @@ class SettingsViewModel @Inject constructor(
}
}
fun setFont(role: FontRole, token: String) {
viewModelScope.launch {
when (role) {
FontRole.BRAND -> prefs.setBrandFont(token)
FontRole.PLAIN -> prefs.setPlainFont(token)
}
fun setFont(role: FontRole, token: String) = save {
when (role) {
FontRole.BRAND -> setBrandFont(token)
FontRole.PLAIN -> setPlainFont(token)
}
}
@@ -486,9 +316,7 @@ class SettingsViewModel @Inject constructor(
}
}
fun setAgendaScreenRange(range: AgendaRange) {
viewModelScope.launch { prefs.setAgendaScreenRange(range) }
}
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
fun setAgendaWidgetRange(range: AgendaRange) {
viewModelScope.launch {
@@ -505,9 +333,7 @@ class SettingsViewModel @Inject constructor(
}
}
fun setAgendaShowRangeBar(enabled: Boolean) {
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
}
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
/**
* Set the size the agenda widget draws its text at (#51), via the same
@@ -541,33 +367,19 @@ class SettingsViewModel @Inject constructor(
}
}
fun setTimeFormat(pref: TimeFormatPref) {
viewModelScope.launch { prefs.setTimeFormat(pref) }
}
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
fun setMultiDayCount(count: Int) {
viewModelScope.launch { prefs.setMultiDayCount(count) }
}
fun setMultiDayCount(count: Int) = save { setMultiDayCount(count) }
fun setShowHourGrid(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
}
fun setShowHourGrid(enabled: Boolean) = save { setShowHourGrid(enabled) }
fun setShowWeekNumbers(enabled: Boolean) {
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
}
fun setShowWeekNumbers(enabled: Boolean) = save { setShowWeekNumbers(enabled) }
fun setTodayButtonInToolbar(enabled: Boolean) {
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
}
fun setTodayButtonInToolbar(enabled: Boolean) = save { setTodayButtonInToolbar(enabled) }
fun setDragToReschedule(enabled: Boolean) {
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
}
fun setDragToReschedule(enabled: Boolean) = save { setDragToReschedule(enabled) }
fun setTapOpensEditor(enabled: Boolean) {
viewModelScope.launch { prefs.setTapOpensEditor(enabled) }
}
fun setTapOpensEditor(enabled: Boolean) = save { setTapOpensEditor(enabled) }
/**
* Switch the launcher label (#44). The card highlights at once, then settles
@@ -598,53 +410,33 @@ class SettingsViewModel @Inject constructor(
}
}
fun setDimCompletedEvents(enabled: Boolean) {
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
}
fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) }
fun setDefaultView(view: CalendarView) {
viewModelScope.launch { prefs.setDefaultView(view) }
}
fun setDefaultView(view: CalendarView) = save { setDefaultView(view) }
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) = save { setFormFieldDefault(field, enabled) }
/**
* Enable or disable [view] in the quick-switch cycle, via an atomic
* read-modify-write so a concurrent reorder can't clobber it. Any number of
* views may be turned off; below two the pill hides itself (#150).
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
prefs.updateQuickSwitch { config ->
config.copy(
enabled = if (enabled) config.enabled + view else config.enabled - view,
)
}
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) = save {
updateQuickSwitch { config ->
config.copy(enabled = if (enabled) config.enabled + view else config.enabled - view)
}
}
/** Reorder the quick-switch views (atomic read-modify-write; keeps the enabled set). */
fun setQuickSwitchOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
}
fun setQuickSwitchOrder(order: List<CalendarView>) = save { updateQuickSwitch { it.copy(order = order) } }
fun setMonthViewStyle(style: MonthViewStyle) {
viewModelScope.launch { prefs.setMonthViewStyle(style) }
}
fun setMonthViewStyle(style: MonthViewStyle) = save { setMonthViewStyle(style) }
fun setTimelineScale(scale: TimelineScale) {
viewModelScope.launch { prefs.setTimelineScale(scale) }
}
fun setTimelineScale(scale: TimelineScale) = save { setTimelineScale(scale) }
fun setTimelineHours(hours: TimelineHours) {
viewModelScope.launch { prefs.setTimelineHours(hours) }
}
fun setTimelineHours(hours: TimelineHours) = save { setTimelineHours(hours) }
fun setDrawerViewOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
}
fun setDrawerViewOrder(order: List<CalendarView>) = save { setDrawerViewOrder(order) }
/**
* A scan follows the write: switching reminders off cancels the scan alarm,
@@ -657,26 +449,16 @@ class SettingsViewModel @Inject constructor(
}
}
fun setAutofocusEventTitle(enabled: Boolean) {
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
}
fun setAutofocusEventTitle(enabled: Boolean) = save { setAutofocusEventTitle(enabled) }
fun setDefaultEventDuration(minutes: Int) {
viewModelScope.launch { prefs.setDefaultEventDurationMinutes(minutes) }
}
fun setDefaultEventDuration(minutes: Int) = save { setDefaultEventDurationMinutes(minutes) }
/** [minutes] null clears the override, so the calendar inherits the default. */
fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
viewModelScope.launch { prefs.setCalendarEventDuration(calendarId, minutes) }
}
fun setCalendarEventDuration(calendarId: Long, minutes: Int?) = save { setCalendarEventDuration(calendarId, minutes) }
fun setDefaultReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
}
fun setDefaultReminderMinutes(minutes: List<Int>) = save { setDefaultReminderMinutes(minutes) }
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
}
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = save { setDefaultAllDayReminderMinutes(minutes) }
/** The armed alarm was planned for the old hour, so re-plan against the new one. */
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
@@ -686,19 +468,15 @@ class SettingsViewModel @Inject constructor(
}
}
fun setSnoozeMinutes(minutes: Int) {
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
}
fun setSnoozeMinutes(minutes: Int) = save { setSnoozeMinutes(minutes) }
fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
}
fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) = save { setCalendarReminderOverride(calendarId, override) }
fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) {
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
}
fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) = save { setCalendarAllDayReminderOverride(calendarId, override) }
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) = save { setAllowColorOnUnsupportedCalendars(enabled) }
private fun save(write: suspend SettingsPrefs.() -> Unit) {
viewModelScope.launch { prefs.write() }
}
}
@@ -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
@@ -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))
}
@@ -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)