Skip to content

feat(ui): Settings theme picker dialog (#87)#111

Merged
ilmoniemi merged 3 commits into
mainfrom
feature/87
May 14, 2026
Merged

feat(ui): Settings theme picker dialog (#87)#111
ilmoniemi merged 3 commits into
mainfrom
feature/87

Conversation

@ilmoniemi
Copy link
Copy Markdown
Contributor

What

Wires the Settings → Appearance → Theme row to an M3 single-choice AlertDialog ("System default" / "Light" / "Dark") and hoists the read + write of ThemeMode out of the Settings route composable into a new SettingsViewModel.

  • SettingsViewModel — exposes themeMode: StateFlow<ThemeMode> via stateIn(viewModelScope, WhileSubscribed(5_000L), SYSTEM) (mirrors ChannelListViewModel shape) and onSelectTheme(mode) that writes through AppPreferences.setThemeMode(...).
  • ThemePickerDialog — new internal composable in ui/settings/. M3 AlertDialog with a selectableGroup() column of three radio rows tracking local pending state (remember(selected)), OK/Cancel buttons; confirm → onConfirm(pending), cancel/dismiss → onDismiss().
  • SettingsScreen — new signature (themeMode, onSelectTheme, onBack, onOpenLicense, …); old themeMode = SYSTEM default deleted. Theme row onClick flips a local showThemeDialog open-state. ThemeMode.label() extension promoted from private to internal so the dialog composable reuses it — single source of truth for the three label strings.
  • MainActivity — Settings route now uses koinViewModel<SettingsViewModel>() + vm.themeMode.collectAsStateWithLifecycle(), replacing direct AppPreferences injection. Root-level appPreferences.themeMode collector that drives PyrycodeMobileTheme(darkTheme = …) is untouched, so the persisted write fans out to both surfaces and the app theme re-renders without a process restart.
  • AppModule — one-line viewModel { SettingsViewModel(get()) } Koin registration.
  • strings.xml — adds settings_theme_dialog_title ("Theme"). Radio labels reuse ThemeMode.label().

Issue

Closes #87

Testing

  • ./gradlew test — pass (new SettingsViewModelTest covers initial state, mirror-of-persisted-value, persistence of each of LIGHT/DARK/SYSTEM, and re-emission after onSelectTheme).
  • ./gradlew lint — pass.
  • ./gradlew assembleDebug — pass.
  • ./gradlew connectedAndroidTest — not run (no device/emulator wired in this worktree). SettingsScreenTest was updated to pass the new params (compile-only verified).

Architecture compliance

Follows the spec at docs/specs/architecture/87-settings-theme-picker-dialog.md verbatim: SettingsViewModel shape mirrors ChannelListViewModel (stateIn + WhileSubscribed(5_000L)); dialog uses OK/Cancel (decided in spec § Design); ThemeMode.label() promoted to internal (decided in spec § Label helper); dialog open-state is local to the screen composable; pending keyed by selected so a reopen picks up an updated persisted value (decided in spec § State + concurrency model); MainActivity root-level collector left intact (decided in spec § Files to read first).

🤖 Generated with Claude Code

ilmoniemi and others added 2 commits May 14, 2026 09:00
Wires the Settings Theme row to an M3 single-choice AlertDialog
("System default" / "Light" / "Dark"). Hoists the row's read + write
of ThemeMode out of the route composable into a new SettingsViewModel
that owns appPreferences.themeMode and an onSelectTheme(...) entry
point. The persisted write fans out to MainActivity's root-level
collector so PyrycodeMobileTheme re-renders without a process restart.

Closes #87

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

@ilmoniemi ilmoniemi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: #87

Decision: PASS

Findings

No MUST FIX / SHOULD FIX findings.

  • [NIT] ThemePickerDialog.kt:43,48 — using android.R.string.ok / android.R.string.cancel for the dialog buttons works (localized for free) but diverges slightly from the rest of the codebase, which keeps button strings in app/src/main/res/values/strings.xml (e.g. promote_dialog_confirm, promote_dialog_cancel). Not a blocker — flagging only because future localisation passes may want consistency. The spec didn't prescribe which path to take.

Verified

  • SettingsViewModel follows ChannelListViewModel's canonical shape exactly — stateIn(viewModelScope, WhileSubscribed(5_000L), initial) for the read flow, viewModelScope.launch for the write. No sealed SettingsState/SettingsEvent (correctly deferred per spec).
  • ThemePickerDialog uses the correct accessibility pattern: Modifier.selectableGroup() on the column + Modifier.selectable(..., role = Role.RadioButton, onClick = …) on each row with RadioButton(onClick = null) — avoids the dual-click-handler trap. MaterialTheme.typography.bodyLarge used; no hardcoded colors / TextStyle() literals anywhere in the diff.
  • remember(selected) keying on pending is correct — if the persisted value changes while the dialog is closed and the parent recomposes with a new selected, the next open picks it up. Confirm path calls onSelectTheme(pending) → VM writes; Cancel/dismiss discards pending via composition exit. Matches AC #2.
  • ThemeMode.label() promoted from private to internal (single source of truth, shared between row subtitle and dialog labels). Not moved into data/preferences/ — correct, these are UI strings and data/ is the CMP walk-back boundary.
  • MainActivity.kt:54-62 (root-level theme collection) untouched. The new VM adds a second subscriber to the same DataStore flow; one write fans out to both surfaces, which is what AC #3 depends on. Verified koinInject import retained (Scanner route + root theme still use it).
  • SettingsViewModelTest reuses the AppPreferencesTest rig (real PreferenceDataStoreFactory + TemporaryFolder + real AppPreferences) — exercises the round-trip end-to-end. All six scenarios from the spec covered, including the flow re-emit gate.
  • SettingsScreenTest (instrumented) updated to pass the new required params; both @Preview composables updated. No stale defaults.
  • ./gradlew :app:testDebugUnitTest and ./gradlew :app:lintDebug clean locally.

Summary

Slice is small, focused, and faithfully tracks the spec. No code-level findings, no acceptance-criteria gaps. Approved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ilmoniemi ilmoniemi merged commit ca744d8 into main May 14, 2026
1 check failed
@ilmoniemi ilmoniemi deleted the feature/87 branch May 14, 2026 06:20
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.

feat(ui): Settings theme picker dialog (System / Light / Dark)

1 participant