1package soy.iko.opencode.ui.settings
3import android.content.pm.PackageManager
5import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.defaultMinSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.rememberScrollState
18import androidx.compose.foundation.selection.selectable
19import androidx.compose.foundation.selection.toggleable
20import androidx.compose.foundation.verticalScroll
21import androidx.compose.foundation.shape.CircleShape
22import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.automirrored.filled.ArrowBack
24import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
25import androidx.compose.material.icons.filled.BugReport
26import androidx.compose.material.icons.filled.Dns
27import androidx.compose.material3.ExperimentalMaterial3Api
28import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.HorizontalDivider
30import androidx.compose.material3.Icon
31import androidx.compose.material3.IconButton
32import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.RadioButton
34import androidx.compose.material3.Scaffold
35import androidx.compose.material3.Switch
36import androidx.compose.material3.Text
37import androidx.compose.material3.TextButton
38import androidx.compose.material3.TopAppBar
39import androidx.compose.runtime.Composable
40import androidx.compose.runtime.getValue
41import androidx.compose.runtime.remember
42import androidx.compose.runtime.rememberCoroutineScope
43import androidx.compose.ui.Alignment
44import androidx.compose.ui.Modifier
45import androidx.compose.ui.draw.clip
46import androidx.compose.ui.graphics.Color
47import androidx.compose.ui.platform.LocalContext
48import androidx.compose.ui.res.stringResource
49import androidx.compose.ui.semantics.Role
50import androidx.compose.ui.semantics.heading
51import androidx.compose.ui.semantics.role
52import androidx.compose.ui.semantics.semantics
53import androidx.compose.ui.text.style.TextAlign
54import androidx.compose.ui.unit.dp
55import androidx.lifecycle.compose.collectAsStateWithLifecycle
56import kotlinx.coroutines.ExperimentalCoroutinesApi
57import kotlinx.coroutines.flow.combine
58import kotlinx.coroutines.flow.flatMapLatest
59import kotlinx.coroutines.flow.flowOf
60import kotlinx.coroutines.launch
61import soy.iko.opencode.data.network.EventStreamClient
62import soy.iko.opencode.data.repo.CrashLogger
63import soy.iko.opencode.data.repo.ThemeMode
64import soy.iko.opencode.di.AppContainer
65import soy.iko.opencode.R
66import soy.iko.opencode.ui.theme.LightPaletteSwatches
67import soy.iko.opencode.ui.theme.DarkPaletteSwatches
68import soy.iko.opencode.util.runCatchingCancellable
70@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
72fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
73 val scope = rememberCoroutineScope()
74 // Combine the three settings into a single nullable state so the appearance section
75 // renders only after the persisted values have loaded, avoiding a brief flash of
76 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
77 // Wrapped in remember so the combined Flow isn't re-created on every recomposition
78 // (which would cancel and relaunch the DataStore collection each time — see the
79 // connectionStateFlow below, remembered for the same reason).
80 val settingsFlow = remember(container) {
82 container.settingsStore.themeMode,
83 container.settingsStore.dynamicColor,
84 container.settingsStore.sendOnEnter,
85 ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
87 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
88 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
89 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
90 // SSE connection state so the Settings screen can show a dropped stream (the
91 // ConnectionBanner surfaces this on the chat and session screens; without it here,
92 // a user who opens Settings during a reconnect wouldn't know the stream dropped).
93 // Wrapped in remember so the Flow operator isn't re-created every recomposition
94 // (which would reset collectAsStateWithLifecycle and cause flicker).
95 val connectionStateFlow = remember {
96 container.activeConnection
97 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
99 val connectionState by connectionStateFlow
100 .collectAsStateWithLifecycle(initialValue = EventStreamClient.ConnectionState.Disconnected)
101 val context = LocalContext.current
102 // Crash count badge: surface that there are reports to look at without making the
103 // user open the screen to find out.
104 val crashLogger = remember { CrashLogger.get(context) }
105 val crashReports by crashLogger.reports.collectAsStateWithLifecycle()
106 val crashCount = crashReports.size
107 val unknownVersion = stringResource(R.string.unknown_version)
108 val versionName = remember {
110 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
111 context.packageManager.getPackageInfo(
113 PackageManager.PackageInfoFlags.of(0),
116 @Suppress("DEPRECATION")
117 context.packageManager.getPackageInfo(context.packageName, 0).versionName
119 }.getOrNull() ?: unknownVersion
125 title = { Text(stringResource(R.string.settings)) },
127 IconButton(onClick = onBack) {
128 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
134 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
137 stringResource(R.string.appearance),
138 style = MaterialTheme.typography.titleSmall,
139 color = MaterialTheme.colorScheme.primary,
140 modifier = Modifier.semantics { heading() },
143 ThemeMode.entries.forEach { mode ->
146 selected = s.themeMode == mode,
147 onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
151 Spacer(Modifier.size(8.dp))
152 // Dynamic color (Material You) only works on Android 12+, so hide the toggle
153 // on older devices instead of offering a control that silently does nothing.
154 if (dynamicColorAvailable) {
159 value = s.dynamicColor,
160 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
163 .padding(vertical = 8.dp),
164 verticalAlignment = Alignment.CenterVertically,
166 Column(modifier = Modifier.weight(1f)) {
167 Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
169 stringResource(R.string.dynamic_color_desc),
170 style = MaterialTheme.typography.bodySmall,
171 color = MaterialTheme.colorScheme.onSurfaceVariant,
175 checked = s.dynamicColor,
176 onCheckedChange = null,
181 // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
182 Spacer(Modifier.size(4.dp))
187 value = s.sendOnEnter,
188 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
191 .padding(vertical = 8.dp),
192 verticalAlignment = Alignment.CenterVertically,
194 Column(modifier = Modifier.weight(1f)) {
195 Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
197 stringResource(R.string.send_on_enter_desc),
198 style = MaterialTheme.typography.bodySmall,
199 color = MaterialTheme.colorScheme.onSurfaceVariant,
203 checked = s.sendOnEnter,
204 onCheckedChange = null,
209 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
210 contentAlignment = Alignment.Center,
212 CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
216 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
219 stringResource(R.string.connection),
220 style = MaterialTheme.typography.titleSmall,
221 color = MaterialTheme.colorScheme.primary,
222 modifier = Modifier.semantics { heading() },
224 if (activeProfile != null) {
225 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
227 activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
228 style = MaterialTheme.typography.bodySmall,
229 color = MaterialTheme.colorScheme.onSurfaceVariant,
231 // Surface the SSE stream state so a user who opens Settings during a
232 // reconnect sees it (the ConnectionBanner on other screens handles this
233 // elsewhere). Only show non-Connected states to avoid clutter.
234 val stateText = when (connectionState) {
235 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
236 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
237 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
238 EventStreamClient.ConnectionState.Connected -> null
243 style = MaterialTheme.typography.bodySmall,
244 color = if (connectionState == EventStreamClient.ConnectionState.Failed)
245 MaterialTheme.colorScheme.error
246 else MaterialTheme.colorScheme.onSurfaceVariant,
247 modifier = Modifier.padding(top = 4.dp),
252 stringResource(R.string.not_connected),
253 modifier = Modifier.padding(top = 8.dp),
254 color = MaterialTheme.colorScheme.onSurfaceVariant,
260 .defaultMinSize(minHeight = 48.dp)
262 .clickable(role = Role.Button) { onManageServers() }
263 .padding(vertical = 8.dp),
264 verticalAlignment = Alignment.CenterVertically,
266 Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
267 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
268 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
271 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
274 stringResource(R.string.about),
275 style = MaterialTheme.typography.titleSmall,
276 color = MaterialTheme.colorScheme.primary,
277 modifier = Modifier.semantics { heading() },
280 stringResource(R.string.about_version, versionName),
281 style = MaterialTheme.typography.bodyMedium,
282 modifier = Modifier.padding(top = 8.dp),
285 stringResource(R.string.about_desc),
286 style = MaterialTheme.typography.bodySmall,
287 color = MaterialTheme.colorScheme.onSurfaceVariant,
288 modifier = Modifier.padding(top = 4.dp),
291 Spacer(Modifier.size(16.dp))
295 .defaultMinSize(minHeight = 48.dp)
296 .clickable(role = Role.Button) { onOpenDiagnostics() }
297 .padding(vertical = 8.dp),
298 verticalAlignment = Alignment.CenterVertically,
300 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
301 Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
302 // Badge with the crash count so the user can tell at a glance whether
303 // there's something worth investigating.
304 if (crashCount > 0) {
305 Badge(count = crashCount)
306 Spacer(Modifier.size(8.dp))
308 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
315private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
316 // Preview swatches so the user can see what each mode looks like before selecting,
317 // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
318 // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
319 val swatches: List<Color> = when (mode) {
320 ThemeMode.SYSTEM -> {
321 val dark = isSystemInDarkThemeStatic()
322 if (dark) DarkSwatches else LightSwatches
324 ThemeMode.LIGHT -> LightSwatches
325 ThemeMode.DARK -> DarkSwatches
330 .selectable(selected = selected, onClick = onSelect)
331 .padding(vertical = 8.dp),
332 horizontalArrangement = Arrangement.spacedBy(8.dp),
333 verticalAlignment = Alignment.CenterVertically,
335 RadioButton(selected = selected, onClick = null)
338 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
339 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
340 ThemeMode.DARK -> stringResource(R.string.theme_dark)
342 style = MaterialTheme.typography.bodyLarge,
343 modifier = Modifier.weight(1f),
345 // Three small color dots previewing the palette for this mode.
346 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
347 swatches.forEach { color ->
359// Palette preview dots for the theme rows. Primary/secondary/tertiary/background convey
360// the character of each scheme at a glance without rendering a full sample surface.
361private val DarkSwatches: List<Color> = DarkPaletteSwatches
362private val LightSwatches: List<Color> = LightPaletteSwatches
364// Composable-side read of the system dark setting for the "System" theme row preview.
365// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
366// helper; hoist it here so the ThemeRow stays a plain @Composable with no preview-time
367// recomposition surprises.
369private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
371/** Small wrapper so the three appearance settings load atomically and the UI can gate
372 * on a single null check instead of flashing hardcoded defaults. */
373private data class SettingsValues(
374 val themeMode: ThemeMode,
375 val dynamicColor: Boolean,
376 val sendOnEnter: Boolean,
379/** Small circular count badge used to indicate pending crash reports. */
381private fun Badge(count: Int) {
382 val overflow = stringResource(R.string.crash_count_overflow)
387 .background(MaterialTheme.colorScheme.error),
388 contentAlignment = Alignment.Center,
391 if (count > 99) overflow else count.toString(),
392 style = MaterialTheme.typography.labelSmall,
393 color = MaterialTheme.colorScheme.onError,
394 textAlign = TextAlign.Center,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list