Skip to content

feat(profile): sync the theme preference to the account, two-way (#36) - #118

Merged
Adron merged 1 commit into
parity/queuefrom
issue/36-settings-theme
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/36-settings-theme

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #36. Part of epic #31.

Finding that shaped the design: theme has no server-side allow-list

The plan was to establish accepted values the way viewingPreference was — PATCH a bogus value and
read the 400. That does not work here. theme is not validated at all. Every one of these was
answered 200 {"message":"User updated successfully"} and stored verbatim:

sent result
"not-a-theme" 200, stored "not-a-theme"
"sepia" 200, stored "sepia"
"SYSTEM" 200, stored "SYSTEM"
"" 200, stored ""

So the vocabulary came from /help/settings instead, which publishes the web's own under Profile
settings
: "Theme: Light, dark, or system (follows your device preference)".

Two consequences, both acted on:

  1. The account does have a "follow the system" option, so ThemeMode.SYSTEM maps straight to
    "system" and is never coerced into light or dark. A user who follows their phone's theme keeps
    doing so after a sync. (That was the open question in the brief; the answer is that no coercion is
    needed.)
  2. The app has to be the conservative side. It sends only light/dark/system, and refuses to
    adopt anything else — an unrenderable stored value like "sepia" is neither applied nor
    overwritten. Both are tested.

The test account was left as found (theme: "light" — independently re-verified).

The reconciliation rule

An unsynced local choice wins; otherwise the account wins.

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.

ThemeSettingsStore is now an interface + SharedPrefsThemeSettingsStore (the
LastSeenNotificationStore pattern), with hasUnsyncedChange persisted alongside the mode so an
offline change survives process death
.

Note the recursion guard: the internal patch() falls back to a raw fetch() rather than
refresh() 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

  • A failed theme save deliberately does NOT roll back, unlike the other Settings rows. Rolling
    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."
  • Cold-start flash: adoption is asynchronous, so a fresh install signing in shows its local
    default briefly, then re-themes. Fixing it would mean blocking the splash on a network read.
  • Sync triggers are refresh() only — shell entry and opening Settings. No connectivity-change
    listener, 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 ConnectivityManager callback is
    the follow-up if it proves too lazy.
  • This adds a third store touching GET /api/user state, so Consolidate the two writers of account preferences behind one owner #104's surface grew — though it adds
    no new :core:network accessor.

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
@Adron
Adron merged commit b263ca4 into parity/queue Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant