From a464617bca4b676e12809b0f2a9f1a3fc55e7304 Mon Sep 17 00:00:00 2001 From: Juhana Ilmoniemi Date: Wed, 13 May 2026 22:15:22 +0300 Subject: [PATCH 1/3] spec: Settings screen matching Figma 17:2 (#64) --- docs/specs/architecture/64-settings-screen.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/specs/architecture/64-settings-screen.md diff --git a/docs/specs/architecture/64-settings-screen.md b/docs/specs/architecture/64-settings-screen.md new file mode 100644 index 0000000..36de863 --- /dev/null +++ b/docs/specs/architecture/64-settings-screen.md @@ -0,0 +1,172 @@ +# #64 — Settings screen matching Figma 17:2 + +Replace the `SettingsPlaceholder` stub with a sectioned Settings screen that mirrors Figma node 17:2: M3 `TopAppBar` + back nav, vertically scrollable body, seven labelled sections of stub rows (chevron / Switch / "Add" pill / external-link / none), no persistence, no ViewModel. + +## Files to read first + +- `app/src/main/java/de/pyryco/mobile/MainActivity.kt:175-189` — current `composable(Routes.Settings)` block + `SettingsPlaceholder`; the wiring you replace and the stub you delete. +- `app/src/main/java/de/pyryco/mobile/MainActivity.kt:193-200` — `Routes` object; confirms `Routes.Settings = "settings"` and that no nav arguments are involved. +- `app/src/main/java/de/pyryco/mobile/ui/conversations/list/DiscussionListScreen.kt:52-99` — canonical pattern for **back-nav screen with `Scaffold` + `TopAppBar` + body**. The new screen mirrors this skeleton; lift the `Icons.AutoMirrored.Filled.ArrowBack` + `IconButton` + `stringResource(R.string.cd_back)` shape verbatim. +- `app/src/main/java/de/pyryco/mobile/ui/conversations/list/ChannelListScreen.kt:49-90` — companion reference; same `@OptIn(ExperimentalMaterial3Api::class)` + `TopAppBar` idiom, slightly different (no back button — uses logo + action). Use as a tiebreaker only. +- `app/src/main/java/de/pyryco/mobile/ui/theme/Theme.kt:257-279` — `PyrycodeMobileTheme(darkTheme, dynamicColor, content)` signature for the two `@Preview` composables. +- `app/src/main/res/values/strings.xml` — confirm `R.string.cd_back` exists (used by `DiscussionListScreen`); add any new string ids you introduce (title, content-descriptions for the icon buttons / "Add" / external-link). Do **not** hardcode user-facing strings. + +Codegraph confirmed `SettingsPlaceholder` has exactly one caller (`MainActivity.PyryNavHost`); no fan-out. + +## Design source + +**Figma:** https://www.figma.com/design/g2HIq2UyPhslEoHRokQmHG?node-id=17-2 + +Full-screen `Schemes/surface` column. Top: small `TopAppBar` with leading `ArrowBack` (24dp icon in a 48dp container) and `title-large` "Settings". Body: vertically scrollable column of seven sections; each section starts with a `label-large` header in `Schemes/primary` (16dp horizontal, 16dp top / 4dp bottom padding), followed by `ListItem`-shaped rows (16dp horizontal / 10dp vertical, `body-large` headline in `on-surface`, optional `body-small` subtitle in `on-surface-variant`) with a trailing 20dp chevron, 18dp `OpenInNew`, M3 `Switch` (52×32), outlined "Add" pill (rounded-100, leading 16dp `+` icon, `label-large` primary text), or no trailing affordance. No dividers between rows; section headers are the visual seam. + +## Context + +Phase 1's #16 wired the `Settings` route to a literal placeholder. Figma 17:2 is the locked design for the screen. This ticket lands the **visual skeleton only** — every row's destination, every toggle's persistence, and every subtitle's data source are Phase 3+ tickets. Subtitle literals ("juhana-mac-2026", "Opus 4.7", build hash, "11 archived", etc.) are intentional placeholders, **not** wired to `BuildConfig` / `DataStore` / any repository. + +No ViewModel, no Koin module, no repository touched. The screen is a pure stateless composable plus three local-only `mutableStateOf` switches. + +## Design + +### Public surface (single new file) + +`app/src/main/java/de/pyryco/mobile/ui/settings/SettingsScreen.kt`: + +```kotlin +@Composable +fun SettingsScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) +``` + +That is the *only* `public` symbol the file exports. No state class, no event sealed type, no ViewModel — there is no hoisted state to flow through `MainActivity` and no navigation other than back. Keep the surface minimal so future tickets can split this into a stateful screen + stateless content without a public API change. + +### Private composables in the same file + +Use M3 `ListItem` as the row primitive. Its three slots (`headlineContent`, `supportingContent`, `trailingContent`) match Figma's row anatomy. Defaults handle padding adequately; AC #6 explicitly permits M3 defaults. + +Private helpers (rough shapes — bodies are the developer's; signatures only here): + +- `SettingsSectionHeader(text: String)` — `Text` with `MaterialTheme.typography.labelLarge` and `MaterialTheme.colorScheme.primary`; padding `start=16.dp, end=16.dp, top=16.dp, bottom=4.dp`. Matches Figma node `17:10/17:23/17:36/...`. +- `SettingsRow(headline, supporting?, trailing: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null)` — wraps `ListItem` with `headlineContent = { Text(headline) }`, optional `supportingContent`, optional `trailingContent`. When `onClick != null`, wrap the `ListItem` in `Modifier.clickable { onClick() }`. Used by every row in the screen. +- Trailing-content lambdas (defined inline at call sites or as small `@Composable` helpers — developer's choice): + - Chevron: `Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp))`. + - External link: `Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp))`. + - Switch: `Switch(checked, onCheckedChange)` — `Switch` is already 52×32 by M3 defaults; no sizing needed. + - "Add" pill: `OutlinedButton(onClick = {}, contentPadding = ButtonDefaults.ContentPadding) { Icon(Icons.Default.Add, ...); Spacer(width=4.dp); Text("Add", style = labelLarge) }`. `OutlinedButton`'s default shape is rounded-pill — matches Figma's `rounded-[100px]`. Specify `colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary)` if the default isn't already primary. + +The chevron and external-link icons are decorative; per Compose accessibility guidance, set `contentDescription = null` when the row's headline text already conveys the action. The "Add" button's icon **does** need a `contentDescription` (it's an interactive control); use a new string id (`R.string.cd_add_memory_plugin` or similar). + +### Body skeleton + +Inside `Scaffold { inner -> }`, body is: + +```kotlin +Column( + modifier = Modifier + .padding(inner) + .verticalScroll(rememberScrollState()) + .fillMaxSize() + .padding(bottom = 32.dp), // matches Figma 17:9 pb=32 +) { + SettingsSectionHeader("Connection") + SettingsRow(headline = "Server", supporting = "juhana-mac-2026", trailing = Chevron, onClick = {}) + SettingsRow(headline = "Pair another server", trailing = Chevron, onClick = {}) + SettingsSectionHeader("Appearance") + // … etc per Acceptance Criteria, section by section, in the order listed. +} +``` + +`Column` + `verticalScroll` over `LazyColumn`: the list is fixed at ~24 items, the heterogeneous row types make LazyColumn item-keying noisy, and there's no recycling benefit. Match the simpler primitive. + +### Section + row inventory + +Take the AC list as the source of truth. Render sections in this order with exactly these rows; subtitle/trailing semantics are PO-locked: + +| Section | Row | Subtitle | Trailing | +|---|---|---|---| +| Connection | Server | `juhana-mac-2026` | Chevron | +| Connection | Pair another server | — | Chevron | +| Appearance | Theme | `System` | Chevron | +| Appearance | Use Material You dynamic color | — | Switch (default **on**) | +| Defaults for new conversations | Default model | `Opus 4.7` | Chevron | +| Defaults for new conversations | Default effort | `high` | Chevron | +| Defaults for new conversations | Default YOLO | `off` | Switch (default **off**) | +| Defaults for new conversations | Default workspace | `scratch` | Chevron | +| Notifications | Push notifications when claude responds | — | Switch (default **on**) | +| Notifications | Notification sound | `Default` | Chevron | +| Memory | Installed memory plugins | `0 plugins` | "Add" pill (outlined, leading `+`) | +| Memory | Manage per-channel memory | — | Chevron | +| Storage | Archived conversations | `11 archived` | Chevron | +| Storage | Clear cache | — | Chevron | +| About | Version 0.1.0 | `build a8f3c2d` | — (none) | +| About | Open source · github.com/pyrycode/pyrycode-mobile | — | `OpenInNew` icon | +| About | Privacy policy | — | `OpenInNew` icon | +| About | License: MIT | — | — (none) | + +Section-header label color is `MaterialTheme.colorScheme.primary` — this is the Figma "Schemes/primary" token, not a custom palette. + +Hardcode the headline / subtitle literals as `String` literals inline at each call site. Per the ticket, these are intentional scaffolding placeholders; do **not** route them through `strings.xml` (resources are reserved for content that will eventually be localised, and these will be replaced wholesale by data wiring in Phase 3 anyway). The screen *title* "Settings", the `cd_back` content description, and any new content-descriptions for the trailing controls **do** belong in `strings.xml`. + +### MainActivity wiring change + +Edit `MainActivity.kt`: + +1. Remove the `private fun SettingsPlaceholder(...)` definition at lines 181–189. +2. Remove unused imports left behind: `androidx.compose.foundation.layout.Column`, `androidx.compose.material3.TextButton` (verify nothing else in the file still references them before removing — `Column` and `TextButton` should both become orphaned, but check). The `Text` import is still used by the `ConversationThread` placeholder at line 173. +3. Add `import de.pyryco.mobile.ui.settings.SettingsScreen`. +4. Replace the body of `composable(Routes.Settings)` (line 175–177) with: + +```kotlin +composable(Routes.Settings) { + SettingsScreen(onBack = { navController.popBackStack() }) +} +``` + +That is the entire MainActivity diff — ~3 net lines changed plus an import. + +## State + concurrency model + +Three local switch states inside `SettingsScreen`: + +```kotlin +var materialYou by remember { mutableStateOf(true) } +var defaultYolo by remember { mutableStateOf(false) } +var pushNotifications by remember { mutableStateOf(true) } +``` + +These exist *purely* so the toggles animate visually when tapped during manual QA. They are not persisted across recompositions caused by configuration changes (no `rememberSaveable`) — that's intentional. Phase 3 tickets will replace these with `DataStore`-backed flows via a `SettingsViewModel`. Adding `rememberSaveable` now would be premature plumbing. + +No `viewModelScope`, no `Flow`s collected, no coroutines launched. Pure synchronous composition; no concurrency surface to specify. + +## Error handling + +None. There are no IO calls, no parsing, no network, no permissions. The screen cannot fail to render. Row taps are no-ops (or a single `Log.d` TODO marker per AC #5). + +If the developer adds any conditional crash paths "just in case" (null-checks on hardcoded literals, try/catch around `Log.d`, etc.), strip them in review — they violate "Don't add error handling, fallbacks, or validation for scenarios that can't happen." + +## Testing strategy + +**Two `@Preview` composables**, both `private`, both in `SettingsScreen.kt`: + +- `SettingsScreenLightPreview` — `PyrycodeMobileTheme(darkTheme = false) { SettingsScreen(onBack = {}) }`, `@Preview(name = "Settings — Light", showBackground = true, widthDp = 412)`. +- `SettingsScreenDarkPreview` — same with `darkTheme = true`, `@Preview(name = "Settings — Dark", showBackground = true, widthDp = 412)`. + +`widthDp = 412` matches Figma's frame width and the rest of the codebase's previews (see `DiscussionListScreen.kt:171`). + +**No unit tests.** No ViewModel, no state machine, no business logic — a `runTest`-backed unit test would only re-assert the AC table above. The two `@Preview`s plus a manual `./gradlew assembleDebug` are sufficient verification. + +**No instrumented tests** (no `ComposeTestRule`). Each tap is a no-op; there is nothing to assert. + +**Verification checklist for the developer (do this before reporting done):** + +1. `./gradlew assembleDebug` builds without warnings. +2. Render both previews in Android Studio's Compose Preview pane. Confirm visually: section order matches the table; each row's trailing affordance matches; switches start in their AC-specified positions; nothing is cropped or overflowing. +3. `./gradlew installDebug` on an emulator (or device), launch app, navigate to Settings via the channel-list top-bar action, confirm scrolling works, back arrow returns to the channel list, the three switches toggle on tap, all other taps are silent. +4. Toggle the system into dark mode; re-enter Settings; confirm `Schemes/primary` section headers stay legible against `Schemes/surface`. + +## Open questions + +- **"Open source · github.com/..." row overflow.** Figma allows two-line wrap (`whitespace-nowrap` is *not* set on this `

`). M3 `ListItem` headline by default wraps to two lines. No action required — leave default. Calling this out so the developer doesn't over-tighten with `maxLines = 1`. +- **"Add" button colors.** Figma renders `Add` text in `Schemes/primary` with no visible border (it's a `rounded-[100px]` pill but the screenshot at 412dp shows it borderless). `OutlinedButton`'s default uses `colorScheme.outline` for the border. If the developer's eyeball check against the screenshot says the M3 default border is too prominent, downgrade to `TextButton` with manual end-padding; either is acceptable. Don't add a custom shape modifier. +- **Theme.kt's `dynamicColor` default is `false`.** The "Use Material You dynamic color" switch in this stub is purely cosmetic — toggling it does **not** actually flip `PyrycodeMobileTheme(dynamicColor = ...)`. That wiring is Phase 3. Do not extend the stub to call back into Theme. From cde4faf9e9929855b1af3a86baeabe1155387e5b Mon Sep 17 00:00:00 2001 From: Juhana Ilmoniemi Date: Wed, 13 May 2026 22:19:00 +0300 Subject: [PATCH 2/3] feat(ui): Settings screen matching Figma 17:2 (#64) Replace the SettingsPlaceholder stub with a sectioned Settings screen mirroring Figma node 17:2: M3 TopAppBar + back nav, seven labelled sections, M3 ListItem rows with chevron / Switch / Add / external-link / no trailing affordances. Pure stateless composable plus three local-only switches; no ViewModel, no persistence. OpenInNew icon is provided as a local vector drawable rather than pulling in material-icons-extended. --- .../java/de/pyryco/mobile/MainActivity.kt | 15 +- .../mobile/ui/settings/SettingsScreen.kt | 265 ++++++++++++++++++ app/src/main/res/drawable/ic_open_in_new.xml | 10 + app/src/main/res/values/strings.xml | 2 + 4 files changed, 279 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/de/pyryco/mobile/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/res/drawable/ic_open_in_new.xml diff --git a/app/src/main/java/de/pyryco/mobile/MainActivity.kt b/app/src/main/java/de/pyryco/mobile/MainActivity.kt index 0e6dabc..c1d2d26 100644 --- a/app/src/main/java/de/pyryco/mobile/MainActivity.kt +++ b/app/src/main/java/de/pyryco/mobile/MainActivity.kt @@ -6,13 +6,11 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -37,6 +35,7 @@ import de.pyryco.mobile.ui.conversations.list.DiscussionListScreen import de.pyryco.mobile.ui.conversations.list.DiscussionListViewModel import de.pyryco.mobile.ui.onboarding.ScannerScreen import de.pyryco.mobile.ui.onboarding.WelcomeScreen +import de.pyryco.mobile.ui.settings.SettingsScreen import de.pyryco.mobile.ui.theme.PyrycodeMobileTheme import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -173,17 +172,7 @@ private fun PyryNavHost( Text("Conversation thread placeholder: $conversationId") } composable(Routes.Settings) { - SettingsPlaceholder(onBack = { navController.popBackStack() }) - } - } -} - -@Composable -private fun SettingsPlaceholder(onBack: () -> Unit) { - Column { - Text("Settings placeholder") - TextButton(onClick = onBack) { - Text("Back") + SettingsScreen(onBack = { navController.popBackStack() }) } } } diff --git a/app/src/main/java/de/pyryco/mobile/ui/settings/SettingsScreen.kt b/app/src/main/java/de/pyryco/mobile/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..502e93a --- /dev/null +++ b/app/src/main/java/de/pyryco/mobile/ui/settings/SettingsScreen.kt @@ -0,0 +1,265 @@ +package de.pyryco.mobile.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.pyryco.mobile.R +import de.pyryco.mobile.ui.theme.PyrycodeMobileTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + ) + } + }, + ) + }, + ) { inner -> + var materialYou by remember { mutableStateOf(true) } + var defaultYolo by remember { mutableStateOf(false) } + var pushNotifications by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .padding(inner) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + SettingsSectionHeader("Connection") + SettingsRow( + headline = "Server", + supporting = "juhana-mac-2026", + trailing = { ChevronIcon() }, + onClick = {}, + ) + SettingsRow( + headline = "Pair another server", + trailing = { ChevronIcon() }, + onClick = {}, + ) + + SettingsSectionHeader("Appearance") + SettingsRow( + headline = "Theme", + supporting = "System", + trailing = { ChevronIcon() }, + onClick = {}, + ) + SettingsRow( + headline = "Use Material You dynamic color", + trailing = { + Switch(checked = materialYou, onCheckedChange = { materialYou = it }) + }, + ) + + SettingsSectionHeader("Defaults for new conversations") + SettingsRow( + headline = "Default model", + supporting = "Opus 4.7", + trailing = { ChevronIcon() }, + onClick = {}, + ) + SettingsRow( + headline = "Default effort", + supporting = "high", + trailing = { ChevronIcon() }, + onClick = {}, + ) + SettingsRow( + headline = "Default YOLO", + supporting = "off", + trailing = { + Switch(checked = defaultYolo, onCheckedChange = { defaultYolo = it }) + }, + ) + SettingsRow( + headline = "Default workspace", + supporting = "scratch", + trailing = { ChevronIcon() }, + onClick = {}, + ) + + SettingsSectionHeader("Notifications") + SettingsRow( + headline = "Push notifications when claude responds", + trailing = { + Switch( + checked = pushNotifications, + onCheckedChange = { pushNotifications = it }, + ) + }, + ) + SettingsRow( + headline = "Notification sound", + supporting = "Default", + trailing = { ChevronIcon() }, + onClick = {}, + ) + + SettingsSectionHeader("Memory") + SettingsRow( + headline = "Installed memory plugins", + supporting = "0 plugins", + trailing = { AddPill(onClick = {}) }, + ) + SettingsRow( + headline = "Manage per-channel memory", + trailing = { ChevronIcon() }, + onClick = {}, + ) + + SettingsSectionHeader("Storage") + SettingsRow( + headline = "Archived conversations", + supporting = "11 archived", + trailing = { ChevronIcon() }, + onClick = {}, + ) + SettingsRow( + headline = "Clear cache", + trailing = { ChevronIcon() }, + onClick = {}, + ) + + SettingsSectionHeader("About") + SettingsRow( + headline = "Version 0.1.0", + supporting = "build a8f3c2d", + ) + SettingsRow( + headline = "Open source · github.com/pyrycode/pyrycode-mobile", + trailing = { ExternalLinkIcon() }, + onClick = {}, + ) + SettingsRow( + headline = "Privacy policy", + trailing = { ExternalLinkIcon() }, + onClick = {}, + ) + SettingsRow(headline = "License: MIT") + } + } +} + +@Composable +private fun SettingsSectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +private fun SettingsRow( + headline: String, + supporting: String? = null, + trailing: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + val rowModifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + ListItem( + modifier = rowModifier, + headlineContent = { Text(headline) }, + supportingContent = supporting?.let { { Text(it) } }, + trailingContent = trailing, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), + ) +} + +@Composable +private fun ChevronIcon() { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) +} + +@Composable +private fun ExternalLinkIcon() { + Icon( + painter = painterResource(R.drawable.ic_open_in_new), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) +} + +@Composable +private fun AddPill(onClick: () -> Unit) { + TextButton( + onClick = onClick, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.cd_add_memory_plugin), + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = "Add", style = MaterialTheme.typography.labelLarge) + } +} + +@Preview(name = "Settings — Light", showBackground = true, widthDp = 412) +@Composable +private fun SettingsScreenLightPreview() { + PyrycodeMobileTheme(darkTheme = false) { + SettingsScreen(onBack = {}) + } +} + +@Preview(name = "Settings — Dark", showBackground = true, widthDp = 412) +@Composable +private fun SettingsScreenDarkPreview() { + PyrycodeMobileTheme(darkTheme = true) { + SettingsScreen(onBack = {}) + } +} diff --git a/app/src/main/res/drawable/ic_open_in_new.xml b/app/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 0000000..e809cbd --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b098df9..f02ae28 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,4 +11,6 @@ Open recent discussions, %d available Pyrycode logo Channels + Settings + Add memory plugin \ No newline at end of file From 7434e26a283784606d06fe6281a78adc176a4e45 Mon Sep 17 00:00:00 2001 From: Juhana Ilmoniemi Date: Wed, 13 May 2026 22:23:23 +0300 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20Settings=20screen=20=E2=80=94=20fea?= =?UTF-8?q?ture=20doc,=20per-ticket=20notes,=20INDEX=20entry=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- docs/knowledge/INDEX.md | 1 + docs/knowledge/codebase/64.md | 51 +++++++++++++ docs/knowledge/features/settings-screen.md | 83 ++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 docs/knowledge/codebase/64.md create mode 100644 docs/knowledge/features/settings-screen.md diff --git a/docs/knowledge/INDEX.md b/docs/knowledge/INDEX.md index 03ca883..8e4b2d2 100644 --- a/docs/knowledge/INDEX.md +++ b/docs/knowledge/INDEX.md @@ -18,6 +18,7 @@ One-line pointers to evergreen docs. Per-ticket implementation notes live under - [ChannelListViewModel](features/channel-list-viewmodel.md) — first project ViewModel; since #26 `combine`s `observeConversations(Channels)` + `observeConversations(Discussions).map { it.size }` into a hot `StateFlow` via `stateIn(WhileSubscribed(5_000), initial = Loading)` with sealed `Loading` / `Empty(recentDiscussionsCount)` / `Loaded(channels, recentDiscussionsCount)` / `Error(message)` variants and a `.catch { }` → `Error` terminal branch (throws on either upstream collapse). First `onEvent` reducer landed in #22 — handles `CreateDiscussionTapped` by calling `repository.createDiscussion()` and emitting a one-shot `ChannelListNavigation.ToThread(id)` via a `Channel<…>(BUFFERED)` exposed as `navigationEvents`; project-wide pattern for suspend-shaped side effects whose nav target depends on the suspend's return value. `RowTapped`, `SettingsTapped`, and `RecentDiscussionsTapped` (#26) sit on a shared `Unit` arm — pure-navigation events routed at the destination. - [DiscussionListViewModel](features/discussion-list-viewmodel.md) — sibling of `ChannelListViewModel` for the unpromoted tier (#24); same `stateIn(WhileSubscribed(5_000), initial = Loading)` projection of `observeConversations(ConversationFilter.Discussions)` into a sealed `Loading` / `Empty` / `Loaded` / `Error` UiState, same `Channel<…>(BUFFERED) → receiveAsFlow()` one-shot navigation seam. Consumed events: `RowTapped` (sends `ToThread(id)` — production navigation also runs at the destination block; the VM-side emit exists so the unit test can assert the nav target), `SaveAsChannelRequested(id)` (#25 — `Unit` stub with `TODO(phase 2)` for the promotion dialog), and `BackTapped` (`Unit`; back is handled at the composable side via `popBackStack()`). - [DiscussionListScreen](features/discussion-list-screen.md) — drilldown for the unpromoted tier (#24), mounted at the `discussions` route; same `Scaffold + LazyColumn + when (state)` skeleton as `ChannelListScreen` but with a back-arrow `navigationIcon` (`Icons.AutoMirrored.Filled.ArrowBack`, `R.string.cd_back`), no FAB, and rows wrapped with `Modifier.alpha(0.65f)` at the call site to signal the secondary tier. Each row in `Loaded` is the private `DiscussionRow(...)` composable (#25) that pairs `SwipeToDismissBox` (right-to-left swipe → "Save as channel…") and a `DropdownMenu` anchored to an inner `Box` (long-press → same action); both gestures emit `DiscussionListEvent.SaveAsChannelRequested(id)` and the VM swallows it in Phase 0. Three `@Preview`s: full Loaded state, long-press menu open (#25, via `menuInitiallyExpanded = true`), side-by-side channel-row-vs-discussion-row. Route is intentionally unwired from the live graph; #26 adds the channel-list pill. +- [Settings screen](features/settings-screen.md) — sectioned stub at the `settings` route, locked to Figma node `17:2` (#64); single `public` `SettingsScreen(onBack, modifier)`, M3 `TopAppBar` ("Settings" + back-arrow `IconButton`) over a `Column(verticalScroll)` body of seven `SettingsSectionHeader` (`labelLarge` / `colorScheme.primary`, padding `16/16/16/4`) + `SettingsRow` (M3 `ListItem` with explicit `containerColor = colorScheme.surface` to defeat the M3 4.x `surfaceContainer` default) groupings, ~18 rows in total. Five trailing-affordance shapes — 20dp `KeyboardArrowRight`, 18dp `painterResource(R.drawable.ic_open_in_new)` per-file vector (stock Material asset; `material-icons-extended` banned), M3 `Switch`, borderless `TextButton` "Add" pill with 16dp leading `+` (architect's spec offered `OutlinedButton` with `TextButton` fallback; developer picked `TextButton` outright after eyeball vs Figma), or none. Three local-only `remember { mutableStateOf(...) }` switches inside the `Scaffold` content lambda (Material You = on, Default YOLO = off, Push notifications = on) — *not* `rememberSaveable`; persistence + ViewModel are Phase 3. Row headlines / subtitles hardcoded inline (scaffolding placeholders to be replaced wholesale by Phase 3 data wiring; only `settings_title` and `cd_add_memory_plugin` land in `strings.xml`). All non-switch row taps are no-op `onClick = {}` lambdas — present so the M3 ripple still fires (rows without an `onClick` read as "dead"). Replaces the Phase 1 `SettingsPlaceholder` private composable in `MainActivity.kt`. - [ChannelListScreen](features/channel-list-screen.md) — first stateless `(state, onEvent)` screen; M3 `Scaffold` with a `TopAppBar` (since #68: 28dp `ic_pyry_logo` `navigationIcon` tinted `colorScheme.primary` inside a 40dp `Box` + `app_name` title + trailing settings-gear `IconButton`, #21) and a trailing `FloatingActionButton` (#22, rendered only in `Loaded`/`Empty` — `Icons.Default.Add`) wraps the four `ChannelListUiState` variants. `Loaded` is a `Column` of a file-local `ChannelsSectionHeader()` ("Channels", `labelLarge` on `onSurfaceVariant @ 0.85f`, #68) → `LazyColumn` of `ConversationRow`s → `RecentDiscussionsPill`; `Empty` keeps the (#26) pill above the centred `"Tap + to start a conversation"` copy, with no section header. Centred-`Text` for `Loading` / `Error` (no pill, no FAB). Dispatches `ChannelListEvent.RowTapped` / `SettingsTapped` / `RecentDiscussionsTapped` (resolved directly to `navController.navigate`) and `CreateDiscussionTapped` (forwarded into `vm.onEvent`) via the destination-level `onEvent` lambda. Destination collects `vm.navigationEvents` in `LaunchedEffect(vm)` for one-shot nav. Introduces the `koinViewModel<…>()` + `collectAsStateWithLifecycle()` call-site shape at the `channel_list` NavHost destination, and pulls `androidx.compose.material:material-icons-core` onto the classpath for `Icons.Default.Settings` / `Icons.Default.Add`. Six previews since #68 (Loaded + Loaded+pill + Empty + Empty+pill light; Loaded + Empty dark). The pill itself (`ui/conversations/components/RecentDiscussionsPill.kt`, #26) is a stateless `Surface`-with-`clickable` affordance (`tonalElevation = 2.dp`, `Role.Button` semantics, `minimumInteractiveComponentSize()` 48dp floor, merged-descendants TalkBack node) that returns early when `count <= 0`. ## Decisions diff --git a/docs/knowledge/codebase/64.md b/docs/knowledge/codebase/64.md new file mode 100644 index 0000000..53f72d5 --- /dev/null +++ b/docs/knowledge/codebase/64.md @@ -0,0 +1,51 @@ +# #64 — Settings screen matching Figma 17:2 + +Replaces the Phase 1 `SettingsPlaceholder` stub at the `settings` route with a sectioned screen locked to Figma node `17:2`. Visual skeleton only — seven labelled sections, ~18 rows, five trailing-affordance shapes (chevron / `Switch` / "Add" pill / `OpenInNew` icon / none), three local-only switch states, no ViewModel, no persistence, no row destinations wired. Row destinations, toggle persistence, and subtitle data wiring are all Phase 3+. + +## Summary + +- New `SettingsScreen(onBack, modifier)` composable in `app/src/main/java/de/pyryco/mobile/ui/settings/SettingsScreen.kt`. Single `public` symbol; all helpers are file-`private`. +- File-private helpers: `SettingsSectionHeader(text)`, `SettingsRow(headline, supporting?, trailing?, onClick?)`, `ChevronIcon()`, `ExternalLinkIcon()`, `AddPill(onClick)`. +- Section-header style: `MaterialTheme.typography.labelLarge` on `MaterialTheme.colorScheme.primary` with padding `(start=16, end=16, top=16, bottom=4)`. Matches Figma "Schemes/primary" label-large. +- Row primitive is M3 `ListItem` with `headlineContent` / `supportingContent` / `trailingContent` slots. `colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)` is set explicitly — without it, `ListItem` defaults to `colorScheme.surfaceContainer`, which floats the rows above the `Scaffold`'s `surface` in both light and dark themes and contradicts the Figma flat-list look. +- Body is `Column(Modifier.padding(inner).fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 32.dp))` — *not* `LazyColumn`. Row count is fixed and recycling buys nothing; the heterogeneous row shapes make item-keying noisy. Trailing `padding(bottom = 32.dp)` matches Figma `17:9` `pb=32`. +- Three local switch states inside the `Scaffold` content lambda: `materialYou = true`, `defaultYolo = false`, `pushNotifications = true`. Plain `remember { mutableStateOf(...) }`, not `rememberSaveable` — config-change persistence is Phase 3. +- "Add" pill ships as `TextButton` (not `OutlinedButton`). Spec offered both; developer's eyeball against the Figma screenshot favored borderless. Leading 16dp `Icons.Default.Add` + 4dp `Spacer` + "Add" `labelLarge`. The icon has `contentDescription = stringResource(R.string.cd_add_memory_plugin)`; chevron and external-link icons are decorative (`contentDescription = null`). +- External-link icon is a per-file vector drawable: `app/src/main/res/drawable/ic_open_in_new.xml` (stock Material `open_in_new`, 24dp viewport, white fill recoloured at render time). Spec bans `material-icons-extended` and other new icon packages — assets ship as raw vectors. +- `MainActivity.kt`: removed the `SettingsPlaceholder` private composable, added `import de.pyryco.mobile.ui.settings.SettingsScreen`, replaced the `composable(Routes.Settings) { ... }` body with `SettingsScreen(onBack = { navController.popBackStack() })`. +- Two `@Preview` composables (`SettingsScreenLightPreview`, `SettingsScreenDarkPreview`) at `widthDp = 412`. No unit tests, no instrumented tests — nothing testable beyond the AC table. + +## Files touched + +- `app/src/main/java/de/pyryco/mobile/ui/settings/SettingsScreen.kt` — new, 266 LOC. Public `SettingsScreen` at `:43`, private helpers `SettingsSectionHeader` at `:191`, `SettingsRow` at `:201`, `ChevronIcon` at `:218`, `ExternalLinkIcon` at `:227`, `AddPill` at `:236`, two `@Preview`s at `:253` / `:261`. +- `app/src/main/java/de/pyryco/mobile/MainActivity.kt:38` — `import de.pyryco.mobile.ui.settings.SettingsScreen`. `:174-176` — `composable(Routes.Settings) { SettingsScreen(onBack = { navController.popBackStack() }) }`. Previous `SettingsPlaceholder` private composable removed. +- `app/src/main/res/drawable/ic_open_in_new.xml` — new vector drawable, 10 LOC. Stock Material `open_in_new` path at 24dp viewport. +- `app/src/main/res/values/strings.xml:14-15` — two new strings: `settings_title` ("Settings"), `cd_add_memory_plugin` ("Add memory plugin"). + +No build-file, DI, theme, data-model, repository, or ViewModel changes. + +## Patterns established + +- **`Column + verticalScroll` over `LazyColumn` for fixed-cardinality settings-shaped lists.** When the row count is bounded by a hand-enumerated AC table (here: 18) and rows are heterogeneous in shape (chevron / switch / pill / icon / none), prefer the simpler primitive. `LazyColumn` earns its weight only when virtualization actually matters; over a fixed ~20-item list it just adds item-keying surface for no recycling benefit. +- **Hardcoded inline `String` literals for explicit scaffolding placeholders.** When a ticket's AC explicitly calls out that subtitle / headline literals are stubs to be replaced wholesale by Phase 3 data wiring ("juhana-mac-2026", "Opus 4.7", "build a8f3c2d", "11 archived"), keep them as inline Kotlin literals — *don't* route through `strings.xml`. Resources are for content destined for localisation or that survives the Phase transition; throwaway scaffolding belongs at the call site so the eventual data wiring is a single-file diff. Screen *titles* and content-descriptions still go through `strings.xml` because those persist. +- **File-private row + section primitives, not a `components/` extraction.** `SettingsSectionHeader` and `SettingsRow` live `private` in `SettingsScreen.kt`. No `ui/settings/components/` directory. Single-call-site primitives stay file-local; promote to a shared `components/` package only when a second screen needs the same shape (matches the `ChannelsSectionHeader` / `CenteredText` precedent in `ChannelListScreen`). +- **Explicit `ListItem` container colour.** `ListItem(colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface))` is the project shape when an `M3 ListItem` should sit flush with the `Scaffold`'s `surface` rather than floating on `surfaceContainer`. Spec the colour explicitly per use; M3 4.x's default of `surfaceContainer` is too eager for flat-list designs. +- **Per-file vector drawables for missing Material icons.** When an icon (`OpenInNew` here) isn't in `material-icons-core` and the spec bans adding `material-icons-extended`, ship the Material asset as a raw `drawable/*.xml` vector and render via `Icon(painter = painterResource(...), ...)`. Sizing happens at the `Modifier.size(...)` call site, not on the asset. +- **Local-only `remember { mutableStateOf(...) }` for visually-cosmetic toggles in Phase 0 stubs.** When a screen has interactive controls that exist solely so manual QA can verify the animation, plain `remember` (not `rememberSaveable`) is correct. Adding `rememberSaveable` would be premature plumbing that the real Phase 3 `DataStore` + `SettingsViewModel` wiring then has to undo. The state survives recomposition (enough to see the switch toggle); it does not survive navigation or config change (intentional). + +## Lessons learned + +- **`ListItem`'s M3 4.x default container is `surfaceContainer`, not `surface`.** First pass rendered every row visibly elevated above the `Scaffold` body in dark mode — a contiguous "card strip" effect that contradicted Figma's flat-list design. The fix is `ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)` at every call site. Heuristic: when a settings/list screen looks "stripey" in dark mode and you didn't ask for stripes, check `ListItem`'s container colour token before chasing the background painter. +- **`OutlinedButton` for a borderless-looking pill draws a visible border at 412dp.** Architect's spec offered `OutlinedButton` with a `TextButton` fallback "if the M3 default border is too prominent". At Figma's 412dp scale `OutlinedButton`'s `colorScheme.outline` stroke reads more strongly than the Figma reference, which intends a borderless pill in `primary`. `TextButton` is the right call out of the gate — the eyeball comparison happened to fall on the spec's branch B. Lesson: when a spec offers two visual variants with "developer's choice on eyeball check", do the check against the actual Figma screenshot rather than reasoning from token names; tokens like `colorScheme.outline` are visually heavier than they sound. +- **"Resources for everything user-facing" is the wrong default for Phase 0 scaffolding.** Reflexively routing the row headlines / subtitles through `strings.xml` would have created ~30 string ids that Phase 3's data-wiring tickets then have to delete in lockstep. The ticket explicitly framed the literals as placeholders; inline `String` literals signal "throwaway" at the call site, which is exactly what the next phase needs to see when it grepping for the wiring seams. Resource files earn their weight for content that persists across phases; for scaffolding, they're noise. +- **Tap-anywhere no-op rows still need `Modifier.clickable` for ripple feedback.** First pass left non-toggle rows without an `onClick` so the AC "no-op" requirement was satisfied. Result: rows had no Material ripple, which read as "broken" in the preview — the visual stub looked dead even before the user knew the destinations weren't wired. Solution: pass an empty `onClick = {}` lambda; the ripple lands, the user sees the row is *intended* to be tappable, and the AC ("no-op") is still met. The `SettingsRow` helper makes the modifier swap branch on `onClick != null`, so passing `onClick = {}` vs omitting it is the visible/dead distinction. + +## Links + +- Issue: https://github.com/pyrycode/pyrycode-mobile/issues/64 +- Spec: `docs/specs/architecture/64-settings-screen.md` +- Figma: https://www.figma.com/design/g2HIq2UyPhslEoHRokQmHG?node-id=17-2 +- Feature doc: [`../features/settings-screen.md`](../features/settings-screen.md) +- Phase 1 stub it replaces: ticket #16 (`SettingsPlaceholder` in `MainActivity`) +- Sibling Figma-anchored tickets: #57 ([`./57.md`](./57.md) — Welcome), #60 ([`./60.md`](./60.md) — Scanner), #61 ([`./61.md`](./61.md) — Scanner Denied), #62 ([`./62.md`](./62.md) — Scanner Connecting), #68 ([`./68.md`](./68.md) — Channel List polish) +- Downstream (Phase 3+): row destinations (Server picker, Theme picker, Default model picker, etc.), `SettingsViewModel` + `DataStore`-backed toggle persistence, `Material You` wiring back into `PyrycodeMobileTheme`, external-link `Intent.ACTION_VIEW` handlers diff --git a/docs/knowledge/features/settings-screen.md b/docs/knowledge/features/settings-screen.md new file mode 100644 index 0000000..b747d23 --- /dev/null +++ b/docs/knowledge/features/settings-screen.md @@ -0,0 +1,83 @@ +# Settings screen + +Sectioned settings screen at the `settings` route. Visual treatment is locked to Figma node `17:2` (since #64). Phase 0 visual skeleton — every row destination, every toggle's persistence, and every subtitle's data source is deferred to Phase 3+ tickets. + +## What it does + +Replaces the prior `SettingsPlaceholder` stub with a scrollable list of seven labelled sections enumerating the canonical settings surface: + +1. **Connection** — `Server`, `Pair another server`. +2. **Appearance** — `Theme`, `Use Material You dynamic color`. +3. **Defaults for new conversations** — `Default model`, `Default effort`, `Default YOLO`, `Default workspace`. +4. **Notifications** — `Push notifications when claude responds`, `Notification sound`. +5. **Memory** — `Installed memory plugins`, `Manage per-channel memory`. +6. **Storage** — `Archived conversations`, `Clear cache`. +7. **About** — `Version 0.1.0`, `Open source · github.com/pyrycode/pyrycode-mobile`, `Privacy policy`, `License: MIT`. + +Each row has one of five trailing-affordance shapes: chevron, M3 `Switch`, outlined "Add" pill, `OpenInNew` icon, or none. The full row inventory (headlines, subtitle literals, trailing per row) is enumerated in [`../codebase/64.md`](../codebase/64.md). + +## How it works + +Pure stateless Composable with three local-only switch states. No `ViewModel`, no DI wiring, no repository touched, no I/O. + +```kotlin +@Composable +fun SettingsScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) +``` + +Skeleton: + +- `Scaffold` with an M3 `TopAppBar` — `title = stringResource(R.string.settings_title)` ("Settings"), `navigationIcon` is the canonical back-arrow `IconButton` (`Icons.AutoMirrored.Filled.ArrowBack` + `R.string.cd_back`) wired to the `onBack` callback. Same shape as `DiscussionListScreen`. +- Body is a `Column(Modifier.padding(inner).fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 32.dp))` — `Column` + `verticalScroll`, not `LazyColumn`, because the row count is fixed at ~18 items and the heterogeneous row shapes make item-keying noisy with no recycling benefit. +- Three `remember { mutableStateOf(...) }` switch states, declared inside the `Scaffold` content lambda: `materialYou = true`, `defaultYolo = false`, `pushNotifications = true`. Intentionally **not** `rememberSaveable` — config-change persistence is Phase 3's job via a `SettingsViewModel` + `DataStore`. + +### Private composables in the same file + +- `SettingsSectionHeader(text)` — `Text` with `MaterialTheme.typography.labelLarge` in `MaterialTheme.colorScheme.primary`, padding `(start=16, end=16, top=16, bottom=4)`. +- `SettingsRow(headline, supporting?, trailing?, onClick?)` — wraps M3 `ListItem` with `headlineContent`, `supportingContent`, `trailingContent`. When `onClick != null`, the `ListItem` modifier becomes `Modifier.clickable(onClick)`; otherwise the row is non-interactive. `colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)` is set explicitly so dark-mode list rows track the same `surface` token as the surrounding `Scaffold`. +- `ChevronIcon()` — 20dp `Icons.AutoMirrored.Filled.KeyboardArrowRight`, decorative (`contentDescription = null`). +- `ExternalLinkIcon()` — 18dp `painterResource(R.drawable.ic_open_in_new)`, decorative. Drawable is a stock Material `open_in_new` vector at 24dp viewport, sized down via `Modifier.size(18.dp)`. Not in `material-icons-core` / `material-icons-extended` because the spec bans new icon packages — the asset ships as a per-file vector drawable instead. +- `AddPill(onClick)` — `TextButton` (not `OutlinedButton`) with 16dp leading `Icons.Default.Add`, `Spacer(width=4.dp)`, and "Add" text in `MaterialTheme.typography.labelLarge`. The architect's spec called `OutlinedButton` with a fallback to `TextButton` if the default border was too prominent; the developer picked `TextButton` outright after the eyeball check against the Figma screenshot. The `+` icon takes `contentDescription = stringResource(R.string.cd_add_memory_plugin)` because it's the only labelled interactive control inside the pill. + +### Strings + +Three string ids in `strings.xml`: `settings_title` ("Settings"), `cd_add_memory_plugin` ("Add memory plugin"), and the pre-existing `cd_back`. **All row headlines and subtitles are hardcoded inline as Kotlin `String` literals** — per the ticket they're intentional scaffolding placeholders that Phase 3 will replace wholesale by data-layer wiring, so routing them through `strings.xml` would create work the resource files can't usefully own. + +## Configuration / usage + +Mounted at the `settings` route in `PyryNavHost` (`MainActivity.kt`): + +```kotlin +composable(Routes.Settings) { + SettingsScreen(onBack = { navController.popBackStack() }) +} +``` + +Entry point is the trailing settings-gear `IconButton` in `ChannelListScreen`'s `TopAppBar` (via `ChannelListEvent.SettingsTapped` → `navController.navigate(Routes.Settings)`). + +## Edge cases / limitations + +- **No persistence.** Toggle a switch, leave the screen, come back — the switch resets to its AC-specified default. This is intentional; the screen is a visual stub. Don't add `rememberSaveable` "just in case" — Phase 3 replaces these with `StateFlow`-backed values from a `SettingsViewModel`. +- **Material You toggle is cosmetic.** Flipping it doesn't actually call back into `PyrycodeMobileTheme(dynamicColor = ...)`. `Theme.kt` defaults `dynamicColor = false`; that wiring is Phase 3. +- **All non-switch row taps are no-ops** including the "Add" pill, the `Open source` link, and the `Privacy policy` link. No `Intent.ACTION_VIEW`, no nav, no toast. Sub-screens and external-link handling are Phase 3+ tickets. + +## Previews + +Two `@Preview`s, both `private`, both at `widthDp = 412` to match Figma's frame width and the rest of the codebase: + +- `SettingsScreenLightPreview` — `PyrycodeMobileTheme(darkTheme = false) { SettingsScreen(onBack = {}) }`. +- `SettingsScreenDarkPreview` — same with `darkTheme = true`. + +No unit tests, no instrumented tests — no ViewModel, no state machine, no business logic to assert. + +## Related + +- Spec: `docs/specs/architecture/64-settings-screen.md` +- Ticket notes: [`../codebase/64.md`](../codebase/64.md) +- Figma node: `17:2` — https://www.figma.com/design/g2HIq2UyPhslEoHRokQmHG?node-id=17-2 +- Phase 1 stub it replaces: ticket #16 (`SettingsPlaceholder` in `MainActivity`) +- Entry point: [Channel list screen](channel-list-screen.md) — settings-gear `IconButton` in the `TopAppBar` +- Sibling back-nav screen pattern: [Discussion list screen](discussion-list-screen.md) — same `TopAppBar` + `IconButton(onBack)` shape