fix(settings): quick-switch write races, override parsing, stale custom fonts

Four fixes across the settings/prefs layer:

- Quick-switch toggles and reorders were read-modify-write against the
  async-echoed UI snapshot, so two rapid interactions reverted each
  other. Writes now go through SettingsPrefs.updateQuickSwitch, an
  atomic transform over the currently-stored value, via intent-level
  ViewModel ops; the MIN_ENABLED floor is re-checked inside the
  transform since the screen's guard reads the stale snapshot.

- parseReminderOverrides treated any unparseable stored value as an
  explicit empty override (no reminder). Only the deliberate 'none'
  sentinel means that now; garbage drops the entry so the calendar
  inherits the global default. Partially-valid values salvage their
  valid minutes.

- Replacing an already-active custom font never refreshed typography:
  the unchanged 'custom' token made AppFontSettings value-equal, so the
  StateFlow never re-emitted. A per-role import stamp now breaks
  equality on re-import (missing key = 0, backward compatible).

- The FontPicker custom preview resolved the font file unmemoized on
  every recomposition (disk stat + fresh FontFamily defeating the
  typeface cache); it's now remembered, keyed on the import stamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 23:01:58 +02:00
parent 44d056f31f
commit 3863e74857
7 changed files with 153 additions and 13 deletions

View File

@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -198,6 +199,22 @@ class SettingsPrefsTest {
)
}
@Test
fun `updateQuickSwitch transforms the stored value, so intents compose`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
// A toggle then a reorder, each expressed against the *stored* config —
// the reorder must not resurrect the just-disabled view (the stale-UI-
// snapshot race this API exists to prevent).
prefs.updateQuickSwitch { it.copy(enabled = it.enabled - CalendarView.Day) }
val newOrder = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day)
prefs.updateQuickSwitch { it.copy(order = newOrder) }
val config = prefs.quickSwitchConfig.first()
assertThat(config.order).containsExactlyElementsIn(newOrder).inOrder()
assertThat(config.enabled).containsExactly(
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
)
}
@Test
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
@@ -319,6 +336,26 @@ class SettingsPrefsTest {
}
}
@Test
fun `garbage per-calendar override values drop the entry, the none sentinel stays explicit`(
@TempDir tempDir: Path,
) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
// Only "none" deliberately means an explicit no-reminder override; a
// value with no valid minutes is garbage and must drop its entry so the
// calendar inherits the global default instead of silently losing
// 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
}
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(
5L, emptyList<Int>(), 9L, listOf(30), 11L, listOf(15),
)
}
@Test
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
@@ -402,6 +439,19 @@ class SettingsPrefsTest {
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
}
@Test
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
// Existing installs have no stamp key — that must read as 0.
assertThat(prefs.brandFontStamp.first()).isEqualTo(0)
assertThat(prefs.plainFontStamp.first()).isEqualTo(0)
prefs.bumpCustomFontStamp(FontRole.BRAND)
prefs.bumpCustomFontStamp(FontRole.BRAND)
prefs.bumpCustomFontStamp(FontRole.PLAIN)
assertThat(prefs.brandFontStamp.first()).isEqualTo(2)
assertThat(prefs.plainFontStamp.first()).isEqualTo(1)
}
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))

View File

@@ -20,6 +20,15 @@ class FontsTest {
assertThat(BundledFont.fromToken(null)).isNull()
}
@Test
fun `a bumped custom-font stamp breaks font-settings equality`() {
// Re-importing keeps the same "custom" token, so only the stamp can make
// the settings re-emit and invalidate remember() keys downstream.
val before = AppFontSettings(brand = FONT_CUSTOM_TOKEN, brandStamp = 1)
assertThat(before.copy(brandStamp = 2)).isNotEqualTo(before)
assertThat(before.copy(plainStamp = 1)).isNotEqualTo(before)
}
@Test
fun `system for both roles returns the untouched default typography`() {
assertThat(calendulaTypography(brand = null, plain = null))