Skip to content

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

@ilmoniemi

Description

@ilmoniemi

User Story

As a Pyrycode mobile user, I want to tap the Theme row in Settings and pick System default / Light / Dark, so I can override my phone's appearance for this app.

Context

The Settings screen (#64) shows a Theme row with a chevron, but onClick = {}. The data path — ThemeMode enum, theme_mode DataStore key on AppPreferences, and reactive theming wiring — landed in sibling slice #86. This ticket adds the user-facing affordance: a single-choice radio dialog that writes the user's pick via AppPreferences.setThemeMode(...).

What #86 left in place:

  • MainActivity collects AppPreferences.themeMode at the root for PyrycodeMobileTheme(darkTheme = …), AND a second time inside composable(Routes.SETTINGS) { … } to pass themeMode: ThemeMode as a parameter into SettingsScreen(...).
  • SettingsScreen takes a themeMode: ThemeMode = ThemeMode.SYSTEM parameter and renders its subtitle via a private ThemeMode.label() extension.

What this ticket changes: the Settings route stops injecting AppPreferences directly and stops passing themeMode as a parameter — it obtains a SettingsViewModel via koinViewModel(...) and forwards state + an event callback into SettingsScreen. The Theme row gains a tap handler that opens the picker dialog.

Material You dynamic color is a separate (future) ticket — this one is ONLY system/light/dark.

Acceptance Criteria

  • Tapping the Theme row opens a single-choice dialog titled "Theme" with three radio rows labelled "System default" / "Light" / "Dark" (same strings as the row subtitle — see existing ThemeMode.label() at SettingsScreen.kt:266); the currently-selected radio reflects the persisted ThemeMode.
  • Confirming the selection persists via AppPreferences.setThemeMode(...); cancelling (or dismissing without confirming) leaves the persisted value unchanged. The architect chooses the M3-spec dialog shape (OK/Cancel buttons vs. tap-radio-to-commit-and-dismiss) — either is acceptable.
  • After a confirming selection, the Theme row subtitle updates and PyrycodeMobileTheme re-renders with the new scheme without a process restart. (Both surfaces collect the same AppPreferences.themeMode flow — MainActivity at the root for the theme, the new SettingsViewModel for the row — so a single setThemeMode(...) write fans out to both.)
  • A SettingsViewModel (Koin-injected via AppModule) exposes themeMode: StateFlow<ThemeMode> and an onSelectTheme(mode: ThemeMode) event entry point. The composable(Routes.SETTINGS) { ... } block in MainActivity obtains it via koinViewModel<SettingsViewModel>() and forwards state + callback into SettingsScreen, replacing the current direct AppPreferences injection and themeMode parameter. Full sealed SettingsState / SettingsEvent types are NOT required at this slice's scope; only the selected ThemeMode and the callback need to be hoisted out of the composable.
  • SettingsViewModelTest covers: initial state mirrors AppPreferences.themeMode; selecting each mode persists via the fake; the exposed flow re-emits after persistence.

Technical Notes

  • The ViewModel may depend on AppPreferences directly — no new repository.
  • The dialog's open-state can be a remember { mutableStateOf(false) } local to the Settings composable; only the selected ThemeMode and the event callback need to be hoisted to the ViewModel.
  • ThemeMode.label() is already a private extension at SettingsScreen.kt:266. Architect's call: either promote it (file-level or into data/preferences/) and reuse for the dialog's radio labels, or duplicate the three-case when locally in the dialog. Don't write a third copy.
  • The dialog itself can live as a private composable in SettingsScreen.kt or in its own file under ui/settings/. Architect's call.

Depends On

Figma

https://www.figma.com/design/g2HIq2UyPhslEoHRokQmHG?node-id=17-2

The Theme row that opens this dialog lives in node 17-2 (Settings Screen). The picker dialog itself has no Figma sub-node — implement with M3 single-choice dialog defaults. If the visual diverges meaningfully from M3 defaults later, a Figma-side ticket can capture it; for this slice, M3 defaults are the spec.

Size Estimate

S — new SettingsViewModel.kt (~30 LOC) + picker dialog composable (~40 LOC, private in SettingsScreen.kt or its own file) + SettingsScreen.kt rewiring (~15 LOC: VM injection, row tap handler, dialog open-state, drop the themeMode parameter) + MainActivity.kt Settings-route rewire (~5 LOC) + AppModule.kt 1-line Koin add + new SettingsViewModelTest.kt (~40 LOC). ~90 LOC production.

Split from #74. Builds on #86.

Metadata

Metadata

Assignees

No one assigned

    Labels

    size:s<100 lines; default for non-trivial tickets

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions