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
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.
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 —ThemeModeenum,theme_modeDataStore key onAppPreferences, 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 viaAppPreferences.setThemeMode(...).What #86 left in place:
MainActivitycollectsAppPreferences.themeModeat the root forPyrycodeMobileTheme(darkTheme = …), AND a second time insidecomposable(Routes.SETTINGS) { … }to passthemeMode: ThemeModeas a parameter intoSettingsScreen(...).SettingsScreentakes athemeMode: ThemeMode = ThemeMode.SYSTEMparameter and renders its subtitle via a privateThemeMode.label()extension.What this ticket changes: the Settings route stops injecting
AppPreferencesdirectly and stops passingthemeModeas a parameter — it obtains aSettingsViewModelviakoinViewModel(...)and forwards state + an event callback intoSettingsScreen. 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
ThemeMode.label()atSettingsScreen.kt:266); the currently-selected radio reflects the persistedThemeMode.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.PyrycodeMobileThemere-renders with the new scheme without a process restart. (Both surfaces collect the sameAppPreferences.themeModeflow —MainActivityat the root for the theme, the newSettingsViewModelfor the row — so a singlesetThemeMode(...)write fans out to both.)SettingsViewModel(Koin-injected viaAppModule) exposesthemeMode: StateFlow<ThemeMode>and anonSelectTheme(mode: ThemeMode)event entry point. Thecomposable(Routes.SETTINGS) { ... }block inMainActivityobtains it viakoinViewModel<SettingsViewModel>()and forwards state + callback intoSettingsScreen, replacing the current directAppPreferencesinjection andthemeModeparameter. Full sealedSettingsState/SettingsEventtypes are NOT required at this slice's scope; only the selectedThemeModeand the callback need to be hoisted out of the composable.SettingsViewModelTestcovers: initial state mirrorsAppPreferences.themeMode; selecting each mode persists via the fake; the exposed flow re-emits after persistence.Technical Notes
AppPreferencesdirectly — no new repository.remember { mutableStateOf(false) }local to the Settings composable; only the selectedThemeModeand the event callback need to be hoisted to the ViewModel.ThemeMode.label()is already a private extension atSettingsScreen.kt:266. Architect's call: either promote it (file-level or intodata/preferences/) and reuse for the dialog's radio labels, or duplicate the three-casewhenlocally in the dialog. Don't write a third copy.SettingsScreen.ktor in its own file underui/settings/. Architect's call.Depends On
2cd2c85) deliveredThemeModeenum,theme_modeDataStore key onAppPreferences, root-levelPyrycodeMobileThemewiring inMainActivity, and the Theme row subtitle. This ticket builds on those primitives.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 inSettingsScreen.ktor its own file) +SettingsScreen.ktrewiring (~15 LOC: VM injection, row tap handler, dialog open-state, drop thethemeModeparameter) +MainActivity.ktSettings-route rewire (~5 LOC) +AppModule.kt1-line Koin add + newSettingsViewModelTest.kt(~40 LOC). ~90 LOC production.Split from #74. Builds on #86.