MOB-1740: App Theme in Settings with Pure Black (OLED) dark look - #2444
MOB-1740: App Theme in Settings with Pure Black (OLED) dark look#2444nesence-m wants to merge 19 commits into
Conversation
Adds a pure-black variant of the dark palette and a Theme screen in Advanced Settings to choose between "Classic dark" and "Pure black". The light theme is untouched. - ui-design-lib: new OledZashiColorsInternal (one-step-down remap of dark surfaces/strokes only; text/brand/accent tokens unchanged), Base.Black, and a matching OLED layer for the legacy ExtendedColors/Material palettes including the top app bar container color. - ZcashTheme gains an isOledDark parameter defaulting to LocalIsOledDark, so nested always-dark screens (ZcashTheme(forceDarkMode = true)) inherit the choice. The navigation bar scrim goes pure black in OLED. - ui-lib: IsOledThemeEnabledStorageProvider (nullable Boolean, null = classic), SetOledThemeUseCase, ThemeSettings screen/state/VM/view, DI bindings, navigation entry, Advanced Settings row with a new moon icon, and EN/ES strings in a per-feature res folder. - MainActivity observes the preference through OldHomeViewModel and passes it into ZcashTheme. - The two activity-history filter button drawables had a #231F20 fill whose only purpose was to blend into the classic dark screen background; it is now transparent so they do not show as charcoal squares on pure black. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The night variants of the settings opt-in header vectors painted #231F20 elements that match the classic dark background. On the pure black (OLED) theme they showed up as charcoal artefacts. Rather than selecting a second asset at runtime, the night resources themselves are now correct on any dark background. Tor and Theme badges used a background-colored 4dp "crop ring" stroked over the disc edge. It only ever hid the outer 2 units of the disc, so the ring is removed and the disc redrawn at its visible radius, 34 to 32. The Currency Conversion illustration used #231F20 knockouts drawn over the neighbouring coins to fake the separation gaps. The knockouts are replaced with real transparent gaps: the back coins are geometrically subtracted, so each coin is now the lune of its own disc minus the disc of the coin in front of it, and the Z glyph is a true hole instead of a painted charcoal shape. Both assets render identically to before on classic dark and correctly on pure black, with no runtime selection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Custom Tabs browser now uses the app's primary surface color for its toolbar and navigation bar in both light and dark color schemes, with the pure black surface used when the OLED theme is enabled. The browser keeps resolving light vs dark from the system. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…2447) Replaces the OLED-only dark theme toggle with a flat 4-way AppearanceMode picker (System/Light/Dark/OLED). System defers to the platform's light/dark setting; Light and Dark force the corresponding theme regardless of system; OLED remains an explicit pure-black choice, never auto-selected by System. Threads the new mode through ZcashTheme's dark-mode resolution, the in-app browser's CustomTabsIntent color scheme, and a reworked Theme settings screen (single-select list instead of two checkboxes), replacing IsOledThemeEnabledStorageProvider/SetOledThemeUseCase with their AppearanceMode-typed equivalents. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
#2449) Compose color locals (MaterialTheme/ZashiColors) don't affect resource- qualifier resolution — drawable-night/values-night assets are picked from the real Configuration.uiMode, which stays whatever the device's actual light/dark setting is. Once appearanceMode lets the app diverge from the system setting (e.g. forced Light on a dark-system device), any -night icon/string still resolves for the device's real dark mode and renders wrong (near-invisible strokes on a light background, and vice versa). Fixes this in ZcashTheme by overriding LocalConfiguration/LocalContext to match the resolved theme, using a ContextThemeWrapper + applyOverrideConfiguration — the same safe pattern Override.kt already uses to wrap this same content root for tests. Deliberately not the existing ConfigurationOverride composable: on an Activity context, Context.createConfigurationContext returns a context wrapping the Activity's *internal* base context, one level deeper than ContextWrapper.baseContext expects, which crashed LocalContext.componentActivity()'s single-level unwrap (KoinActivityViewModel.kt) for every screen below — reproduced via a real crash on-device (java.lang.ClassCastException: Context is not a ComponentActivity) before switching to this approach. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…2450) Pure black only ever makes sense alongside a dark appearance, so it's no longer a value of AppearanceMode (now just SYSTEM/LIGHT/DARK). It's a separate, independently-persisted boolean applied whenever the resolved theme is dark - reachable from both System (when the device is dark) and Dark, not just a standalone fourth radio choice. Theme settings screen: 3-option radio list (System/Light/Dark) plus an "Pure black" checkbox below, disabled when Light is selected since a theme that never renders dark has nothing for it to modify. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…-black-theme # Conflicts: # CHANGELOG.md
App Theme now lives as its own entry in the top-level Settings (More) list instead of Advanced Settings. The Theme screen drops the OLED checkbox down to a System/Light/Dark radio choice; tapping System or Dark opens a new "When your device is dark" bottom sheet to pick Classic Dark or Pure Black, while Light saves directly. Saving from either screen now pops all the way back to the Settings list via NavigationRouter.backTo(MoreArgs::class).
Describe the final UX: App Theme in Settings with System/Light/Dark, a dark-look bottom sheet for Classic Dark vs Pure Black, and the matching in-app browser theming.
The Theme screen and dark-look sheet now share a dedicated ThemeOption card instead of the exchange-rate Option: 16dp corner radius, bgPrimary fill in both states, a 1dp strokeSecondary border unselected vs. a 2dp textPrimary border selected, and a Compose-drawn radio indicator (filled textPrimary circle with a bgPrimary dot at the exact 8/20 ratio when checked, a Checkboxes.boxOffStroke outline otherwise) so pure black renders correctly instead of via the static ic_checkbox_* drawables. The shared exchangerate.settings.Option composable is untouched. Also raises the OLED palette's strokeSecondary from Shark.900 to Shark.800 so unselected card borders (and strokes generally) stay visible on pure black backgrounds.
Extract ZashiRadioButton's checked/unchecked scale-in/out layering into a public ZashiRadioIndicator(isChecked, checkedContent, uncheckedContent) in ui-design-lib, with ZashiRadioButton's own RadioButtonIndicator delegating to it unchanged (same springs, same layering). ThemeOption now renders its token-drawn checked/unchecked circles through that shared indicator instead of a static Box switch, so the Theme screen and dark-look sheet radios animate the same way as the migration/choose-server ones instead of snapping.
MigrationModeOption keeps its Material-radio look exactly - a 20dp ring with a 2dp stroke and a centered 12dp filled dot when selected, replicated in Compose via a Canvas that mirrors Material3's own RadioButton drawing - but now renders it through the shared ZashiRadioIndicator instead of a plain Material3 RadioButton, so it scales in/out with the same spring animation as the Theme screen and every other unified radio. Card styling, warning-ring drawBehind, haptics-free selectable semantics (Role.RadioButton stays on the row), and layout are unchanged. Only one RadioButton usage existed in feature-migration, so no other call sites needed the swap.
FakeOrchardMigrationSdk was missing OrchardMigrationSdk.migratableOrchardTotal(), a method already present on the SDK worktree this task workspace builds against, which broke compilation of the whole feature-migration test source set (verified via git stash that this pre-dates this branch's Theme work). Stub it the same way every other not-yet-exercised method in this fake is stubbed - notImpl(...) - so it fails loudly if a scenario ever calls it instead of silently returning a guessed value.
ZcashTheme sat exactly at the cyclomatic-complexity threshold; the three parallel light/dark/OLED palette selections now resolve through a single themePalettes helper, which also keeps the families from drifting apart. isServerCustom folds its second early-return into the final expression to satisfy ReturnCount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEsDpz5eEVnrmLqZCes7Ym
|
@zodl-review |
🤖 Claude ReviewCoverageFull diff and file contents reviewed. SummaryAdds a top-level "App Theme" settings entry (System/Light/Dark) with a "When your device is dark" bottom sheet for Classic Dark vs Pure Black (OLED) selection. The OLED palette work is wired into an Risk AssessmentMedium — theme state is read at every recomposition root; the Key ObservationsCorrectness / BugsSave button always enabled in saveButton =
ButtonState(
stringRes(R.string.currencyConversion_saveBtn),
onClick = ::onSaveClick,
hapticFeedbackType = HapticFeedbackType.Confirm
),This is inconsistent with Tautological test in
private fun onSaveClick() =
viewModelScope.launch {
setAppearanceMode(selectedMode.value, isOledEnabled = false)
}This path is only reachable when
android:strokeColor="#231F20"
val resolvedContext =
remember(activityContext, resolvedConfiguration) {
object : ContextThemeWrapper(activityContext, null) {
init {
applyOverrideConfiguration(resolvedConfiguration)
}
}
}
Resources / PerformanceTwo sequential preference reads in private suspend fun startWebBrowser(route: ExternalUrl) {
WebBrowserUtil.startActivity(
activity = activity,
url = route.url,
appearanceMode = appearanceModeStorageProvider.getOrSystem(),
isOledEnabled = isOledEnabledStorageProvider.get() == true
)
}The already-collected
Design / Refactoring
Suggestions
Automated review by |
…invariants Addresses zodl-review findings on PR #2444: - ThemeDarkLookVM now tracks the originally stored OLED choice and disables Save when the selection matches it, mirroring ThemeSettingsVM. - ThemeDarkLookVMTest.assertSingleSelection no longer asserts Save is always enabled; added a dedicated test for the disabled/enabled transition. - ThemeSettingsVM.onSaveClick now asserts the LIGHT-only precondition behind its hardcoded isOledEnabled = false. - ThemeSettingsVM.createState pins the card order explicitly instead of relying on AppearanceMode.entries declaration order. - The AppearanceMode/IsOledEnabled fakes in both VM test files are now backed by MutableStateFlow so observe() stays consistent with get(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Addressed in c91554e:
Skipped per the review's own framing (informational, non-blocking): the
|
LukasKorba
left a comment
There was a problem hiding this comment.
Blocked on one item: the fix round introduced a functional regression that makes the core flow of the feature unreachable. Finding #1 was implemented literally, but the bot's premise was wrong for this screen, and applying it broke Save.
Blocking
You can no longer switch to System or Dark unless you also change the dark look.
ThemeDarkLookVM.kt:61 — isEnabled = oledEnabled != originalOledEnabled.
The VM only knows the OLED boolean (its constructor takes args, navigationRouter, isOledEnabledStorageProvider, setAppearanceMode — no AppearanceModeStorageProvider). But its Save is the only path that persists the appearance mode for SYSTEM and DARK:
ThemeSettingsVM.kt:76-82—onModeClickforwards everything that isn't LIGHT to the sheet and deliberately doesn't touchselectedMode.ThemeDarkLookVM.kt:71-74—onSaveClickcallssetAppearanceMode(args.mode, …), which is what writesappearance_mode.SetAppearanceModeUseCasehas exactly two call sites in the tree, and that's the one that matters here.
Repro from stored LIGHT + is_oled_enabled=false (also the never-chosen default):
- Tap Dark → sheet opens with Classic Dark preselected, so
selectedOledEnabled == originalOledEnabled == false. isEnabled = false != false→ Save disabled.- No other control on the sheet. Dismiss is
onBack()→navigationRouter.back(), nothing persisted.
So Dark-with-Classic-Dark is unreachable, and the only way to reach Dark at all is via Pure Black. Same for SYSTEM ↔ DARK in either direction while the look stays put, and for LIGHT → SYSTEM. This worked at cdc2782 (the parent), where ButtonState had no isEnabled and defaulted to true.
The invariant this sheet actually needs is (oledEnabled != originalOledEnabled) || (args.mode != storedAppearanceMode), which means injecting AppearanceModeStorageProvider — or keep it always-enabled and document why. The bot's "mirror ThemeSettingsVM" reasoning doesn't transfer: the Theme screen's Save commits one value, the sheet's commits two.
And the tests now pin the broken behaviour. ThemeDarkLookVMTest.kt:81-91 asserts it as correct. :94-115 seeds stored = LIGHT, args = DARK but clicks Pure Black before Save, so it never touches the mode-changed-look-unchanged path — and dropping assertTrue(state.saveButton.isEnabled) from assertSingleSelection (:130-141) removed the one assertion that would have caught this.
On the other findings
Card order (ThemeSettingsVM.kt:52) and the test fakes (both files now MutableStateFlow-backed) are addressed. Two notes: nothing pins the card order — ThemeSettingsVMTest.optionFor:167-170 uses .first { it.mode == mode }, order-insensitive — and ThemeSettingsView.kt:222 still uses AppearanceMode.entries in the preview. The observe() fake change is cosmetic since nothing exercises it.
You were right to push back on the ic_settings_theme.xml finding — both call sites tint (SettingsListItemLeadingIcon.kt:26-31, ThemeSettingsView.kt:171-176) and ColorFilter.tint overrides the vector's stroke. Same for the ContextThemeWrapper one: onApplyThemeResource only runs on getTheme() and the object is rebuilt when the key changes, so that IllegalStateException path isn't reachable. Both bot false alarms.
Should-fix
Flash of the wrong theme on cold start. MainActivity.kt:171-177 reads the theme from OldHomeViewModel, whose flows seed with SYSTEM/false (OldHomeViewModel.kt:39-49,52-60); the backing read is Dispatchers.IO.limitedParallelism(1) so it can't land before first composition. The splash gate is SecretState.LOADING (MainActivity.kt:159), unrelated, and SPLASH_SCREEN_DELAY = 0.seconds. Dark device + Light chosen → dark frames then light, and vice versa. It's a full repaint, not a subtle tint, because the flip swaps LocalContext/LocalConfiguration (ZcashTheme.kt:122-125) and the whole tree re-resolves. Gating setKeepOnScreenCondition on the first appearance-mode emission closes it. (The read itself is correctly off the main thread — that part's clean.)
Nits
ThemeSettingsVM.kt:86-89—requireinsideviewModelScope.launchis a production crash, not an assertion. If the precondition ever breaks (someone adds a mode that doesn't forward to the sheet — the exact case the bot worried about) theIllegalArgumentExceptionescapes to the default handler. Early return + log documents the invariant without shipping a crash.ThemeSettingsScreen.kt:14—BackHandler { state?.onBack() }is unconditionally enabled, so a back press during the null window is swallowed.enabled = state != null.ThemeOption.kt:82-86— plainclickable, noRole.RadioButton/selectableGroup, so TalkBack announces three generic buttons with no selected state. Not a regression (matchesexchangerate.settings.Option) butMigrationModeOptionin this same PR does it properly.- Same seed-then-correct shape as the cold-start flash on both theme screens (
ThemeSettingsVM.kt:29-47,ThemeDarkLookVM.kt:27-45) — one frame can render System/Classic-Dark checked regardless of what's stored. ui/theme_settings/drawable/ic_theme_settings.xmland its-nighttwin are added and referenced by nothing. Leftovers from the earlier moon-icon commit; the res folder is still needed for the strings.ThemeSettingsView.kt:171-176— 40dp icon whose art occupies the inner 20 units, in a 40dpBoxwith anotherpadding(10.dp); net glyph ≈10dp vs ≈20dp in the Settings row. Worth an eyeball against Figma.
Checked and clear
I went looking for M3 tonal-elevation grey leaking into pure black and it isn't there: fully token-driven, BlankBgScaffold uses Surfaces.bgPrimary, the sheet container Surfaces.bgSecondary, all three tonalElevation usages are 0.dp, and there's no surfaceContainer*/surfaceVariant/surfaceTint anywhere — so OledColorPalette copying only surface/background is fine. System bars are right (ZcashTheme.kt:147-169, DefaultOledScrim on the nav bar). No hardcoded colors in the new Compose code, light mode untouched. splash_screen_background stays #231F20 under OLED, but that's pre-existing and out of scope.
Scope
All defensible, none silent, but flagging what touches existing screens: three drawable-night assets (ic_tor_settings, two transaction-filter icons) had #231F20 fills removed so they don't show as charcoal squares on pure black — the tor one redraws the disc at r=32 instead of stroking a ring over r=34, claimed pixel-identical on classic dark. Worth a designer's eye on classic dark, not just OLED. OledZashiColors.kt raises strokeSecondary Shark.900 → Shark.800, OLED-only. And MigrationSetupScreen.kt:309-375 swaps M3 RadioButton for the new shared ZashiRadioIndicator — geometry replicates M3's and Role.RadioButton survives, but ZashiRadioButton.kt:174-178 documents checkedContent as needing to be opaque while MigrationRadioCircle is a stroked ring; it only works because the geometry matches exactly. Tighten that KDoc.
The previous round applied a review bot's "mirror ThemeSettingsVM" suggestion literally and gated the sheet's Save on the dark look alone. But this sheet's Save is the only path that persists the appearance mode for System and Dark, so from stored Light + Classic Dark the user could no longer reach Dark or System at all without also flipping the look - and the tests had been changed to pin that broken behaviour. Inject AppearanceModeStorageProvider, read the stored mode alongside the stored OLED flag in init, and enable Save whenever either the mode or the look differs from what is stored. The state now stays null until both stored values have been read, so the sheet no longer paints a seeded Classic Dark selection first. ThemeDarkLookVMTest covers all four corners of the invariant - same mode + same look, mode changed + look unchanged, mode unchanged + look changed, and the never-chosen defaults with Dark args - and assertSingleSelection pins the Save state on every call, which is the assertion whose removal let the regression through. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
Cold start read the theme from OldHomeViewModel's flows, which seed with System/Classic Dark, so a device whose stored appearance differs painted a few frames of the wrong theme and then repainted the whole tree. Both stored values now come from one combined upstream, and the new isThemeResolved flag holds the splash screen until that read lands; SPLASH_SCREEN_DELAY is untouched. Also from the review: - ThemeSettingsVM.onSaveClick logs and returns instead of crashing on the Light-only invariant, and its state stays null until the stored mode is read. - ThemeSettingsScreen's BackHandler is enabled only once state exists. - ThemeOption is selectable with Role.RadioButton, and both theme screens wrap their cards in a selectableGroup, matching MigrationModeOption. - Removed the unreferenced ic_theme_settings drawable and its -night twin. - The Theme header glyph is drawn at the full circle size, the same way the Settings row draws it, instead of at roughly half that. - The ThemeSettingsView preview uses the explicit System/Light/Dark order, and ThemeSettingsVMTest pins that order. - ZashiRadioIndicator's checkedContent KDoc states the actual contract: the geometry has to match the indicator, opacity is not required. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
|
Thanks for the deep pass, @LukasKorba — you were right on every count. Two commits: Blocking — Save unreachable for System/Dark. Owning this plainly: the previous round applied the bot's "mirror Should-fix — flash of the wrong theme on cold start. Both stored values now come from one combined upstream in Nits — all done (
On scope. The three Gates: |
| @OptIn(ExperimentalMaterial3Api::class) | ||
| @Composable | ||
| fun ZcashTheme( | ||
| forceDarkMode: Boolean = false, |
There was a problem hiding this comment.
remove this flag completely - we can force appearanceMode param as AppearanceMode.Dark instead
There was a problem hiding this comment.
Done in 373ae74 - forceDarkMode is gone from ZcashTheme; the always-dark screens and dark previews now pass appearanceMode = AppearanceMode.DARK, and the former forceDarkMode = false call sites just drop the argument.
| * all derive from a single upstream read and can never disagree about whether it has landed yet. Null | ||
| * until that read completes. | ||
| */ | ||
| private val theme: StateFlow<ThemeAppearance?> = |
There was a problem hiding this comment.
lets not use OldHomeViewModel at all but instead lets make ThemeVM
There was a problem hiding this comment.
Done in 64e099a - the theme state moved to a new ThemeVM next to ThemeSettingsVM/ThemeDarkLookVM (registered in viewModelModule, read by MainActivity), and OldHomeViewModel is back to its pre-PR shape; covered by ThemeVMTest.
The flag duplicated what AppearanceMode.DARK already expresses. Every always-dark screen and dark preview now passes appearanceMode = AppearanceMode.DARK; the forceDarkMode = false call sites simply drop the argument and keep inheriting LocalAppearanceMode. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
The stored appearance mode and dark look have nothing to do with the legacy home screen state, so they get their own activity-scoped ThemeVM next to ThemeSettingsVM and ThemeDarkLookVM. The single combined upstream and the eagerly shared isThemeResolved splash gate are kept as they were; MainActivity now reads all three from ThemeVM. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXV241FzSZi6wy2PXPNz3S
LukasKorba
left a comment
There was a problem hiding this comment.
The Save regression is properly fixed — that one's closed. Two things before this merges, and a note on scope.
The regression — fixed, and the tests are better than I asked for
ThemeDarkLookVM.kt:27 injects AppearanceModeStorageProvider, :48 reads storedMode, and :80 is the invariant. I walked all six cases including the genuine no-op:
| stored mode / oled | args.mode | selection | Save |
|---|---|---|---|
| LIGHT / false | DARK | Classic Dark | enabled — the original repro, now reachable |
| LIGHT / false | SYSTEM | Classic Dark | enabled |
| DARK / false | SYSTEM | unchanged | enabled |
| SYSTEM / false | DARK | unchanged | enabled |
| DARK / true | DARK | Classic Dark | enabled |
| DARK / false | DARK | Classic Dark | disabled — correct, genuine no-op |
And you avoided the seed-then-correct race I was worried about: selectedOledEnabled starts null (:35) and state maps null→null (:39), so isSaveEnabled can't run before the stored read, and both stored values are assigned at :48-49 before the update at :50 in the same launch. Save is never briefly wrong in either direction.
Making isSaveEnabled a required parameter of assertSingleSelection (:247) so every caller has to state it is stronger than the assertion I asked you to restore. saveEnabledWhenModeDiffersEvenThoughLookIsUnchanged (:123) asserts with no click first, which is exactly the missing case. Only gap left is stored=DARK→args=SYSTEM, which only has its mirror tested.
All seven nits addressed. Cold-start gate at MainActivity.kt:167 is sound — isThemeResolved derives from the same combined upstream, SharingStarted.Eagerly, and both prefs emit unconditionally, so it does complete.
Before merge
1. componentActivity() unwraps exactly one level and this PR can stack two wrappers. KoinActivityViewModel.kt:44-50 is context is ContextWrapper && context.baseContext is ComponentActivity, else throw. ScreenshotTest.kt:128 sets configurationOverrideFlow on every test → Override.kt:32-44 wraps LocalContext → ContextThemeWrapper(Activity); MainActivity.kt:177-181 then renders ZcashTheme inside that, and ZcashTheme.kt:109-120 wraps again → ContextThemeWrapper(ContextThemeWrapper(Activity)). AndroidHome.kt:19's koinActivityViewModel<ZashiTopAppBarVM>() then sees a base context that isn't a ComponentActivity and throws.
The KDoc at ZcashTheme.kt:94-100 argues the pattern is safe because it "matches the same safe pattern Override.kt already uses" — that holds for one wrap and is falsified by stacking with the very component it cites. Please fix the reasoning along with the code: either a while unwrap loop in componentActivity(), or don't re-wrap when the ambient context is already a wrapper.
This is the note you skipped in the earlier round as "informational, non-blocking". The finding as originally written was about applyOverrideConfiguration, and I agreed with you that that specific path wasn't reachable — but the double-wrap underneath it is a different and real defect, so the dismissal took the wrong half.
To be straight about what I did and didn't verify: I confirmed the unwrap logic and the two wrap sites by reading head, and test_android_modules_emulator (app) is red. I could not confirm from the check annotations that this is what's failing it — that same job is also red on #2497, which doesn't touch theming, and both show only generic runner annotations. So please confirm against the job log which of the two it is before you spend time on it.
2. The head commit doesn't match your own description. You said the state was gated on the first theme emission in OldHomeViewModel; head is 64e099a, four commits past the block, and 373ae74+64e099a (14:53, after your comment) relocate the theme state into a new ThemeVM across 24 files in the design lib. Same thing I flagged on #2494 an hour ago: I'm reviewing a diff whose changelog describes something else. Not asking you to revert it — just say what you pushed, and I'll read the right thing the first time.
Two soft edges in that new code:
MainActivity.kt:167—||short-circuits, sothemeVMisn't constructed whilesecretStateisLOADING; the theme read is serialized after the wallet read instead of running alongside it. Reorder the operands and the gate costs nothing.ThemeVM.kt:29-42— no.catchor timeout. IfpreferenceHolder()throws, thestateIncoroutine dies and the splash never lifts.ThemeVMTest.kt:74pins that shape as intended. The failure mode I reported was "wrong theme, app opens"; the fix turns it into "app never opens", which is worse. Worth a timeout that falls back to SYSTEM.
Nit
ThemeOption.kt:124,141 — ThemeRadioIndicator's modifier is applied inside both content lambdas rather than on the root. Inert today since no caller passes one, wrong if anyone does.
Keystone is a Maestro home.syncComplete timeout, unrelated to this.
What
Final MOB-1740 UX per the Q3 designs (Figma node 7011-6045):
How
ThemeSettingsVM/Viewreworked (no checkbox; System/Dark forward to the sheet route); newscreen/theme/darklook/package withThemeDarkLookArgs/State/VM/Viewregistered asdialogComposable, mirroring the SwapSlippage sheet pattern.SetAppearanceModeUseCasenow ends withnavigationRouter.backTo(MoreArgs::class).ic_settings_themepalette drawable.Testing
ThemeSettingsVMTest(7) + newThemeDarkLookVMTest(5) — all green.