Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/main/java/de/pyryco/mobile/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,15 @@ class MainActivity : ComponentActivity() {
val appPreferences = koinInject<AppPreferences>()
val themeMode by appPreferences.themeMode
.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
val useWallpaperColors by appPreferences.useWallpaperColors
.collectAsStateWithLifecycle(initialValue = false)
val darkTheme =
when (themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
PyrycodeMobileTheme(darkTheme = darkTheme) {
PyrycodeMobileTheme(darkTheme = darkTheme, dynamicColor = useWallpaperColors) {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val paired: Boolean? by produceState<Boolean?>(
initialValue = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,16 @@ class AppPreferences(
dataStore.edit { prefs -> prefs[THEME_MODE] = mode.name }
}

val useWallpaperColors: Flow<Boolean> =
dataStore.data.map { prefs -> prefs[USE_WALLPAPER_COLORS] ?: false }

suspend fun setUseWallpaperColors(enabled: Boolean) {
dataStore.edit { prefs -> prefs[USE_WALLPAPER_COLORS] = enabled }
}

private companion object {
val PAIRED_SERVER_EXISTS = booleanPreferencesKey("paired_server_exists")
val THEME_MODE = stringPreferencesKey("theme_mode")
val USE_WALLPAPER_COLORS = booleanPreferencesKey("use_wallpaper_colors")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,21 @@ class AppPreferencesTest {
dataStore.edit { it[stringPreferencesKey("theme_mode")] = "PURPLE" }
assertEquals(ThemeMode.SYSTEM, prefs.themeMode.first())
}

@Test
fun useWallpaperColors_defaultsToFalse() =
runBlocking {
assertEquals(false, prefs.useWallpaperColors.first())
}

@Test
fun setUseWallpaperColors_roundTripsBothValues() =
runBlocking {
prefs.setUseWallpaperColors(false)
assertEquals(false, prefs.useWallpaperColors.first())
prefs.setUseWallpaperColors(true)
assertEquals(true, prefs.useWallpaperColors.first())
prefs.setUseWallpaperColors(false)
assertEquals(false, prefs.useWallpaperColors.first())
}
}
2 changes: 1 addition & 1 deletion docs/knowledge/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ One-line pointers to evergreen docs. Per-ticket implementation notes live under
- [Data model](features/data-model.md) — `Conversation` / `Session` / `Message` schema in `data/model/`; shared shape across mobile, Discord, and pyrycode CLI consumers. Sentinel `DefaultScratchCwd` co-lives in `Conversation.kt` as the single-source-of-truth marker for unbound-workspace conversations. `Conversation.isSleeping: Boolean = false` (#20) is derived in Phase 1 and wire-parsed in Phase 4. `Conversation.archived: Boolean = false` (#93) is the authoritative archive bit — flipped by `archive(id)`, parsed from the wire in Phase 4.
- [Conversation repository](features/conversation-repository.md) — `ConversationRepository` interface + `ConversationFilter` (`All` / `Channels` / `Discussions` / `Archived` since #93), `ThreadItem` (`MessageItem` / `SessionBoundary`), `BoundaryReason`; data-layer seam every UI tier binds to. Phase 1 binding is the in-memory `FakeConversationRepository`; the `observeConversations` projection also stamps the derived `Conversation.isSleeping` flag (#20). Filter matrix (#93): `All` returns everything including archived; `Channels` / `Discussions` carry an explicit `!archived` clause; `Archived` returns archived rows regardless of `isPromoted`. `archive(id)` since #93 flips the flag instead of removing the entry; `unarchive(id)` since #96 is the symmetric inverse (silent-idempotent flag flip back to `false`, same `unknown(id)` throw on missing).
- [Dependency injection](features/dependency-injection.md) — Koin scaffold; single `appModule` at `di/AppModule.kt`, `startKoin` owned by `PyryApp.onCreate`. Downstream tickets append `single`/`viewModel` lines.
- [App preferences](features/app-preferences.md) — `AppPreferences` wrapper over a shared `DataStore<Preferences>` (file `app_prefs`); typed `Flow<T>` reads + `suspend` setters for non-secret app state. Keys: `pairedServerExists` (#11/#12/#13), `themeMode: Flow<ThemeMode>` (#86 — `stringPreferencesKey("theme_mode")` holding the enum's `.name`; `entries.firstOrNull { it.name == stored } ?: SYSTEM` fallback for both absent + unparseable; consumed reactively at `MainActivity`'s `setContent` root to resolve `PyrycodeMobileTheme(darkTheme = …)` and inside `composable(Routes.SETTINGS)` to drive the Theme row subtitle).
- [App preferences](features/app-preferences.md) — `AppPreferences` wrapper over a shared `DataStore<Preferences>` (file `app_prefs`); typed `Flow<T>` reads + `suspend` setters for non-secret app state. Keys: `pairedServerExists` (#11/#12/#13), `themeMode: Flow<ThemeMode>` (#86 — `stringPreferencesKey("theme_mode")` holding the enum's `.name`; `entries.firstOrNull { it.name == stored } ?: SYSTEM` fallback for both absent + unparseable; consumed reactively at `MainActivity`'s `setContent` root to resolve `PyrycodeMobileTheme(darkTheme = …)` and inside `composable(Routes.SETTINGS)` to drive the Theme row subtitle), `useWallpaperColors: Flow<Boolean>` (#88 — `booleanPreferencesKey("use_wallpaper_colors")`, default `false`; collected as a sibling to `themeMode` at `setContent`'s root and forwarded into `PyrycodeMobileTheme(dynamicColor = …)`, whose internal `Build.VERSION.SDK_INT >= S` gate at `Theme.kt:275` handles the pre-S fall-through to the brand palette; matching `setUseWallpaperColors(...)` setter is wired by sibling ticket #89's visible Material You switch row).
- [ConversationRow](features/conversation-row.md) — stateless M3 `ListItem`-based row consuming `Conversation` + `Message?`; auto-name fallback branched on `isPromoted`, whitespace-normalized 2-line preview, kotlinx-datetime relative-time bucketing (`just now` / `Nm ago` / `Nh ago` / `Yesterday` / `Nd ago` / abbreviated date). Headline is a `Row` with two natural-width decorations: workspace label (#19, trailing) and sleeping-session dot (#20, leading — 8.dp `Box` filled with `colorScheme.outline`, gated on `Conversation.isSleeping`). Optional `onLongClick: (() -> Unit)? = null` (#25) switches the modifier chain from `Modifier.clickable` to `Modifier.combinedClickable` so callers can attach long-press affordances without fanning a screen-specific concept into the shared primitive. Since #68 the `ListItem` carries a `leadingContent = { ConversationAvatar(conversation) }` slot. Shared between the #46 channel list and the discussions drilldown.
- [ConversationAvatar](features/conversation-avatar.md) — 40dp leading bubble for `Conversation`-backed rows (#68); deterministic hash-based palette selection via `Math.floorMod(name.hashCode(), 3)` across `primary`/`secondary`/`tertiary` `*-container` pairs, 2-letter lowercase initials from `deriveInitials(name, fallback = conversation.id)` (first letter of first two words after splitting on `[^\p{L}\p{N}]+`; single-word and null/blank fallbacks pad with `'?'`). `paletteIndexFor` and `deriveInitials` are `internal` non-`@Composable` helpers so `./gradlew test` covers them as plain JUnit. Decorative — no `contentDescription`.
- [DiscussionPreviewRow](features/discussion-preview-row.md) — stateless preview-row primitive for the inline Recent-discussions section (#69); `Column` of titleMedium title (auto-fallback `R.string.untitled_discussion`), 2-line bodyMedium placeholder body (`R.string.discussion_preview_placeholder_body` — real previews deferred until the data layer surfaces a per-`Conversation` last-message projection), and a single-line `<workspace> · <relative-time>` meta `Text` rendering an `AnnotatedString` with `SpanStyle(fontFamily = FontFamily.Monospace)` applied only to the workspace fragment. Workspace label diverges from `ConversationRow.condenseWorkspace`: always returns a string, falls back to `R.string.discussion_preview_workspace_scratch` ("scratch") for `DefaultScratchCwd` *and* for empty trailing segments. Sole call site is `RecentDiscussionsSection` inside `ChannelListScreen`; shares the `formatRelativeTime` helper extracted into `ui/conversations/components/RelativeTime.kt` (`internal`, same package) — moved out of `ConversationRow.kt` so both call sites consume one definition.
Expand Down
40 changes: 40 additions & 0 deletions docs/knowledge/codebase/88.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# #88 — Persist `useWallpaperColors` preference + dynamic `ColorScheme` branching

Adds a third boolean DataStore key (`use_wallpaper_colors`, default `false`) on `AppPreferences` with a `Flow<Boolean>` getter + `suspend` setter, then collects it at the composition root and forwards it into `PyrycodeMobileTheme`'s pre-existing `dynamicColor: Boolean = false` parameter. The theme function's internal SDK gate (`Build.VERSION.SDK_INT >= Build.VERSION_CODES.S`) already falls back to the brand `lightScheme` / `darkScheme` when `dynamicColor = true` is passed on a pre-S device, so no Android-version branching exists at the call site — and `Theme.kt` (the Material Theme Builder export) is intentionally untouched. Invisible-by-default: the only path to flip the new key is the new setter, and the sibling Settings row that will consume it lives in #89.

## Summary

- `AppPreferences.kt` — adds `useWallpaperColors: Flow<Boolean>` + `suspend fun setUseWallpaperColors(enabled: Boolean)` mirroring the existing `pairedServerExists` shape exactly. New `booleanPreferencesKey("use_wallpaper_colors")` companion constant; no new imports (the boolean builder is already imported for `PAIRED_SERVER_EXISTS`).
- `MainActivity.kt` — second `collectAsStateWithLifecycle(initialValue = false)` collector immediately after the existing `themeMode` collect at the top of `setContent`; theme call becomes `PyrycodeMobileTheme(darkTheme = darkTheme, dynamicColor = useWallpaperColors) { … }`. The `darkTheme` resolution block from #86 is unchanged.
- `AppPreferencesTest.kt` — two new tests next to the existing five: `useWallpaperColors_defaultsToFalse` and `setUseWallpaperColors_roundTripsBothValues` (single function that walks `false → true → false`, exercising both round-trip and flow-re-emission ACs in one body — the architect's spec called this out as preferable to a duplicate flow-re-emit test).

No changes to `Theme.kt`, `AppModule.kt`, `SettingsScreen.kt`, `SettingsViewModel.kt`, drawables, or `strings.xml`. The 18+ `@Preview` call sites of `PyrycodeMobileTheme(...)` keep working because `dynamicColor` has a `false` default and the new call site passes it as a named argument.

## Files touched

- `app/src/main/java/de/pyryco/mobile/data/preferences/AppPreferences.kt:31-43` — new flow + setter + key constant; placed below `themeMode` (Appearance grouping) and after `THEME_MODE` in the companion (matching the surface order).
- `app/src/main/java/de/pyryco/mobile/MainActivity.kt:62-70` — new `useWallpaperColors` collector; theme call gains `dynamicColor = useWallpaperColors`.
- `app/src/test/java/de/pyryco/mobile/data/preferences/AppPreferencesTest.kt:80-96` — two new `@Test` methods.

## Patterns established

- **Reuse `PyrycodeMobileTheme`'s existing parameter rather than thread a pre-resolved `ColorScheme`.** The ticket's AC offered either route ("explicit parameter or composition root computing it before the call"). Picking the boolean parameter that's already on the function preserves the `darkTheme: Boolean` precedent from #86 ([[caller-computes-darkTheme]] in `86.md`) and avoids changing a signature with 18+ `@Preview` callers. The rule generalises: if the theme primitive already accepts a boolean for the dimension you're wiring, extend the call site, not the signature. Passing a `ColorScheme` directly would invert the encapsulation `Theme.kt` was designed around and force every preview to compute its own scheme.
- **Sibling boolean-flow collectors stack at the same `setContent` scope.** `MainActivity.setContent` now hosts two activity-level `collectAsStateWithLifecycle` calls back-to-back — `themeMode` (#86) and `useWallpaperColors` (#88) — both feeding one `PyrycodeMobileTheme(...)` invocation. Future cross-cutting theme-or-locale prefs that need to recompose the entire app should follow this shape: keep the collectors siblings at the `setContent` top, not nested in a child composable. Drilling them through `PyryNavHost` would route globally-relevant state through navigation boilerplate.
- **`initialValue = false` matches the cold-flow default, no flash on first frame.** Same shape `86.md` established for `initialValue = ThemeMode.SYSTEM` — pick the value the cold flow would emit first on a fresh DataStore. The general rule is now codified in [App preferences#Usage](../features/app-preferences.md) and applies to every future `collectAsStateWithLifecycle(initialValue = ...)` site here.
- **One round-trip test covers both "round-trip" and "flow re-emits" ACs.** The architect's spec noted that a separate `useWallpaperColors_flowReemitsAfterWrite` test would duplicate work — each `first()` call after a write resubscribes and *cannot* see the new value unless DataStore re-emits, so the round-trip test exercises both contracts. Two near-identical test methods is the noisier shape; one method with `false → true → false` is the simpler one. Apply the same compression to any future boolean preference where the ACs separately call out "round-trip" and "flow re-emits."

## Lessons learned

- **`PyrycodeMobileTheme.dynamicColor` already gates the SDK check internally — don't re-check at the call site.** The architect's spec flagged this, and reading `Theme.kt:275` confirms it: `dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S` is the canonical gate, and `dynamicColor = true` on a pre-S device cleanly falls through to the brand `lightScheme` / `darkScheme`. Wrapping the call site in an extra `if (Build.VERSION.SDK_INT >= S)` would be a redundant defense for a code path the theme primitive already owns. Min SDK 33 means pre-S devices aren't reachable in production anyway, but the gate is correct defense regardless.
- **Companion-object key ordering is cosmetic — group by surface, not by type.** `THEME_MODE` is a `stringPreferencesKey` and `USE_WALLPAPER_COLORS` is a `booleanPreferencesKey`, but placing `USE_WALLPAPER_COLORS` after `THEME_MODE` (both Appearance-section preferences) reads better than sorting by builder type. The spec called this out as cosmetic; future Appearance-section keys (per-row notification sound profile, default model, default effort — all enumerated in [Settings screen](../features/settings-screen.md)) should land at the end of the section group, not interleaved alphabetically.
- **`setUseWallpaperColors` is intentionally unused on the main path.** No production caller wires the setter in this slice — #89 will. The lesson: a `suspend` setter without a caller is not dead code when the sibling ticket that consumes it is already filed; trying to "demonstrate the setter" from a debug menu or test-only entry point would be code that ships and then has to be unshipped. The smoke-test verification in the PR description (temporary write from a debug code path on a Pixel 8 emulator API 34) is the right form of "yes, recomposition works" without burdening the production tree.

## Links

- Issue: https://github.com/pyrycode/pyrycode-mobile/issues/88
- Spec: `docs/specs/architecture/88-use-wallpaper-colors-preference.md`
- Depends on: #86 ([`./86.md`](./86.md)) — the composition-root collector pattern + `darkTheme: Boolean` precedent this ticket extends one more parameter
- Sibling ticket (deferred): #89 — visible Material You switch row in Settings → Appearance; consumer-of-record for `setUseWallpaperColors(...)` via the row's `onCheckedChange`
- Split from: #75 (broader Material You / dynamic-color UX work)
- Feature docs touched: [App preferences](../features/app-preferences.md)
- Theme primitive (untouched): `app/src/main/java/de/pyryco/mobile/ui/theme/Theme.kt:264-289` — `PyrycodeMobileTheme(darkTheme: Boolean = …, dynamicColor: Boolean = false, …)`; SDK gate at `Theme.kt:275`
Loading
Loading