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
15 changes: 2 additions & 13 deletions app/src/main/java/de/pyryco/mobile/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() })
}
}
}
Expand Down
265 changes: 265 additions & 0 deletions app/src/main/java/de/pyryco/mobile/ui/settings/SettingsScreen.kt
Original file line number Diff line number Diff line change
@@ -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 = {})
}
}
10 changes: 10 additions & 0 deletions app/src/main/res/drawable/ic_open_in_new.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z" />
</vector>
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@
<string name="cd_recent_discussions_pill">Open recent discussions, %d available</string>
<string name="cd_pyrycode_logo">Pyrycode logo</string>
<string name="channels_section_header">Channels</string>
<string name="settings_title">Settings</string>
<string name="cd_add_memory_plugin">Add memory plugin</string>
</resources>
1 change: 1 addition & 0 deletions docs/knowledge/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChannelListUiState>` 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
Expand Down
Loading