feat(profile): sync the theme preference to the account, two-way (#36) - #118
Merged
Merged
Conversation
Dark mode was device-local: `ThemeSettingsStore` held the choice and
`MainActivity` rendered from it, but nothing ever reached the account, so a
user who picked dark on the web still landed in light on a fresh install.
The account's `theme` field now participates two ways. `ThemeSettingsStore`
becomes an interface (SharedPreferences impl, mirroring
`LastSeenNotificationStore`) that additionally remembers whether the stored
mode has reached the account. `SettingsRepository.setThemeMode` writes the
device first and unconditionally, then PATCHes `theme` alone;
`SettingsRepository.refresh` is the sync point that reconciles the two.
The reconciliation rule is explicit rather than emergent: an unsynced local
choice wins, otherwise the account wins. Neither side carries a timestamp, but
an unsynced flag is happened-after evidence — the account's value is by
construction whatever was there before a change that never got out. So a
change made offline is pushed rather than silently undone, while a device with
nothing pending adopts the account's choice, which is what lands a fresh
install in the theme picked on the web. An absent `theme`, or one this app
cannot render, changes nothing on either side.
Wire values come from `/help/settings` ("Light, dark, or system (follows your
device preference)"), because the API will not tell us: unlike
`viewingPreference`, `theme` has no server-side validation at all — a PATCH of
"sepia", or of "", is answered 200 and stored verbatim. Since the account has
a real "follows the device" option, a local SYSTEM choice maps to "system" and
is never coerced into light or dark.
The control sits in the Settings **Profile** group, between where Avatar would
be and the message character limit, matching the web's own ordering. It reads
the device store rather than `settings.theme`, so it shows what the app is
actually rendering. A failed save is the one on this screen that does not roll
back — the app has already re-themed — and says so.
`AccountThemeSyncEffect` runs the sync when the signed-in shell is entered,
which covers both a cold start with a session and a fresh sign-in; without it
the theme would only reconcile for users who opened Settings.
Tests: `ThemeSyncTest` pins the rule and the wire mapping in both directions;
`SettingsThemeSyncTest` drives the repository through MockWebServer (local
change PATCHes `theme` alone, account value adopted at sign-in, an offline
change survives — including across a process death — and syncs on reconnect,
a pending change beats the account value, a failed push stays owed);
`SettingsThemeTest` covers the screen state and the no-rollback behaviour.
Closes #36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #36. Part of epic #31.
Finding that shaped the design:
themehas no server-side allow-listThe plan was to establish accepted values the way
viewingPreferencewas — PATCH a bogus value andread the 400. That does not work here.
themeis not validated at all. Every one of these wasanswered
200 {"message":"User updated successfully"}and stored verbatim:"not-a-theme""not-a-theme""sepia""sepia""SYSTEM""SYSTEM"""""So the vocabulary came from
/help/settingsinstead, which publishes the web's own under Profilesettings: "Theme: Light, dark, or system (follows your device preference)".
Two consequences, both acted on:
ThemeMode.SYSTEMmaps straight to"system"and is never coerced into light or dark. A user who follows their phone's theme keepsdoing so after a sync. (That was the open question in the brief; the answer is that no coercion is
needed.)
light/dark/system, and refuses toadopt anything else — an unrenderable stored value like
"sepia"is neither applied noroverwritten. Both are tested.
The test account was left as found (
theme: "light"— independently re-verified).The reconciliation rule
Neither side carries a modification timestamp, so "newest wins" is unavailable. What is available
is a persisted unsynced flag on the device store, and that flag is happened-after evidence: if a
change never reached the account, the account's value is by construction whatever was there before
it. Hence — pending change → push it, never overwrite; nothing pending → any difference came from the
web or another device and is newer → adopt it, which is exactly what lands a fresh install in the
web's theme. Account value absent or unrenderable → leave both sides alone. One shortcut: a pending
change the account already holds is marked synced rather than re-sent.
ThemeSettingsStoreis now an interface +SharedPrefsThemeSettingsStore(theLastSeenNotificationStorepattern), withhasUnsyncedChangepersisted alongside the mode so anoffline change survives process death.
Note the recursion guard: the internal
patch()falls back to a rawfetch()rather thanrefresh()on a thin echo body, so a push issued by reconciliation can never loop back into it.Theme sits in the Profile group — #32's hunch, now confirmed against
/help/settings.Verification
./gradlew :app:assembleDebug testDebugUnitTest→ BUILD SUCCESSFUL, 1225 tests, 0 failures(re-confirmed with
--rerun-tasks). 4 Compose tests compile, not executed (no emulator).Deviations and risks
back would undo an offline change — the exact failure this issue rules out. The banner reads the
server's reason plus "Your theme is applied on this device and will sync to your account later."
default briefly, then re-themes. Fixing it would mean blocking the splash on a network read.
refresh()only — shell entry and opening Settings. No connectivity-changelistener, so a pending push settles on the next shell entry rather than the instant the radio
returns. That matches the issue's "reconciling on next sync"; a
ConnectivityManagercallback isthe follow-up if it proves too lazy.
GET /api/userstate, so Consolidate the two writers of account preferences behind one owner #104's surface grew — though it addsno new
:core:networkaccessor.