@@ -2,6 +2,8 @@ package soy.iko.opencode.ui.settings
3import android.content.pm.PackageManager
3import android.content.pm.PackageManager
4import android.os.Build
4import android.os.Build
5+import androidx.activity.compose.rememberLauncherForActivityResult
6+import androidx.activity.result.contract.ActivityResultContracts
5import androidx.compose.foundation.background
7import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
8import androidx.compose.foundation.clickable
7import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Arrangement
@@ -24,6 +26,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
24import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
26import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
25import androidx.compose.material.icons.filled.BugReport
27import androidx.compose.material.icons.filled.BugReport
26import androidx.compose.material.icons.filled.Dns
28import androidx.compose.material.icons.filled.Dns
29+import androidx.compose.material.icons.filled.Download
30+import androidx.compose.material.icons.filled.Hub
31+import androidx.compose.material.icons.filled.QueryStats
32+import androidx.compose.material.icons.filled.Upload
27import androidx.compose.material3.ExperimentalMaterial3Api
33import androidx.compose.material3.ExperimentalMaterial3Api
28import androidx.compose.material3.CircularProgressIndicator
34import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.HorizontalDivider
35import androidx.compose.material3.HorizontalDivider
@@ -32,14 +38,19 @@ import androidx.compose.material3.IconButton
32import androidx.compose.material3.MaterialTheme
38import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.RadioButton
39import androidx.compose.material3.RadioButton
34import androidx.compose.material3.Scaffold
40import androidx.compose.material3.Scaffold
41+import androidx.compose.material3.Slider
35import androidx.compose.material3.Switch
42import androidx.compose.material3.Switch
36import androidx.compose.material3.Text
43import androidx.compose.material3.Text
37import androidx.compose.material3.TextButton
44import androidx.compose.material3.TextButton
38import androidx.compose.material3.TopAppBar
45import androidx.compose.material3.TopAppBar
39import androidx.compose.runtime.Composable
46import androidx.compose.runtime.Composable
40import androidx.compose.runtime.getValue
47import androidx.compose.runtime.getValue
48+import androidx.compose.runtime.mutableFloatStateOf
49+import androidx.compose.runtime.mutableStateOf
41import androidx.compose.runtime.remember
50import androidx.compose.runtime.remember
42import androidx.compose.runtime.rememberCoroutineScope
51import androidx.compose.runtime.rememberCoroutineScope
52+import androidx.compose.runtime.saveable.rememberSaveable
53+import androidx.compose.runtime.setValue
43import androidx.compose.ui.Alignment
54import androidx.compose.ui.Alignment
44import androidx.compose.ui.Modifier
55import androidx.compose.ui.Modifier
45import androidx.compose.ui.draw.clip
56import androidx.compose.ui.draw.clip
@@ -60,17 +71,27 @@ import kotlinx.coroutines.flow.flowOf
60import kotlinx.coroutines.launch
71import kotlinx.coroutines.launch
61import soy.iko.opencode.data.network.EventStreamClient
72import soy.iko.opencode.data.network.EventStreamClient
62import soy.iko.opencode.data.repo.CrashLogger
73import soy.iko.opencode.data.repo.CrashLogger
74+import soy.iko.opencode.data.repo.SettingsStore
63import soy.iko.opencode.data.repo.ThemeMode
75import soy.iko.opencode.data.repo.ThemeMode
64import soy.iko.opencode.di.AppContainer
76import soy.iko.opencode.di.AppContainer
65import soy.iko.opencode.R
77import soy.iko.opencode.R
66import soy.iko.opencode.ui.canAuthenticateForAppLock
78import soy.iko.opencode.ui.canAuthenticateForAppLock
79+import soy.iko.opencode.ui.components.showToast
67import soy.iko.opencode.ui.theme.LightPaletteSwatches
80import soy.iko.opencode.ui.theme.LightPaletteSwatches
68import soy.iko.opencode.ui.theme.DarkPaletteSwatches
81import soy.iko.opencode.ui.theme.DarkPaletteSwatches
69import soy.iko.opencode.util.runCatchingCancellable
82import soy.iko.opencode.util.runCatchingCancellable
83+import kotlin.math.roundToInt
71@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
85@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
72@Composable
86@Composable
73−fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
87+fun SettingsScreen(
88+ container: AppContainer,
90+ onManageServers: () -> Unit,
91+ onOpenDiagnostics: () -> Unit,
92+ onOpenUsage: () -> Unit = {},
93+ onOpenMcp: () -> Unit = {},
74 val scope = rememberCoroutineScope()
95 val scope = rememberCoroutineScope()
75 // Combine the three settings into a single nullable state so the appearance section
96 // Combine the three settings into a single nullable state so the appearance section
76 // renders only after the persisted values have loaded, avoiding a brief flash of
97 // renders only after the persisted values have loaded, avoiding a brief flash of
@@ -87,6 +108,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
87 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
108 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
89 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
110 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
111+ // Text/display prefs are collected separately from [settingsFlow] (kotlinx combine
112+ // tops out at 5 typed flows) and render independently — they don't need the same
113+ // load-gate as the appearance block.
114+ val chatTextScale by container.settingsStore.chatTextScale
115+ .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
116+ val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
90 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
117 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
91 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
118 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
92 // SSE connection state so the Settings screen can show a dropped stream (the
119 // SSE connection state so the Settings screen can show a dropped stream (the
@@ -121,6 +148,40 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
121 }.getOrNull() ?: unknownVersion
148 }.getOrNull() ?: unknownVersion
151+ // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
152+ // import reads a chosen file and applies it. Feedback is a toast (this screen has no snackbar).
153+ var includePasswords by rememberSaveable { mutableStateOf(false) }
154+ val exportedMsg = stringResource(R.string.backup_exported)
155+ val exportFailedMsg = stringResource(R.string.backup_export_failed)
156+ val importedMsg = stringResource(R.string.backup_imported)
157+ val importFailedMsg = stringResource(R.string.backup_import_failed)
158+ val exportLauncher = rememberLauncherForActivityResult(
159+ ActivityResultContracts.CreateDocument("application/json"),
161+ if (uri == null) return@rememberLauncherForActivityResult
163+ val ok = runCatchingCancellable {
164+ val text = container.backupManager.export(includePasswords)
165+ context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
166+ ?: error("no output stream")
168+ showToast(context, if (ok) exportedMsg else exportFailedMsg)
171+ val importLauncher = rememberLauncherForActivityResult(
172+ ActivityResultContracts.OpenDocument(),
174+ if (uri == null) return@rememberLauncherForActivityResult
176+ val ok = runCatchingCancellable {
177+ val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
178+ ?: error("no input stream")
179+ container.backupManager.import(text)
181+ showToast(context, if (ok) importedMsg else importFailedMsg)
124 Scaffold(
185 Scaffold(
125 topBar = {
186 topBar = {
126 TopAppBar(
187 TopAppBar(
@@ -250,6 +311,41 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
251 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
312 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
315+ stringResource(R.string.text_display),
316+ style = MaterialTheme.typography.titleSmall,
317+ color = MaterialTheme.colorScheme.primary,
318+ modifier = Modifier.semantics { heading() },
320+ ChatTextSizeControl(
321+ scale = chatTextScale,
322+ onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
324+ Spacer(Modifier.size(4.dp))
330+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
333+ .padding(vertical = 8.dp),
334+ verticalAlignment = Alignment.CenterVertically,
336+ Column(modifier = Modifier.weight(1f)) {
337+ Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
339+ stringResource(R.string.wrap_code_blocks_desc),
340+ style = MaterialTheme.typography.bodySmall,
341+ color = MaterialTheme.colorScheme.onSurfaceVariant,
344+ Switch(checked = codeWrap, onCheckedChange = null)
347+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
254 stringResource(R.string.connection),
350 stringResource(R.string.connection),
255 style = MaterialTheme.typography.titleSmall,
351 style = MaterialTheme.typography.titleSmall,
@@ -316,6 +412,70 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
316 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
412 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
317 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
413 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
416+ icon = Icons.Filled.QueryStats,
417+ label = stringResource(R.string.usage_title),
418+ enabled = activeProfile != null,
419+ onClick = onOpenUsage,
422+ icon = Icons.Filled.Hub,
423+ label = stringResource(R.string.mcp_servers),
424+ enabled = activeProfile != null,
425+ onClick = onOpenMcp,
428+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
431+ stringResource(R.string.backup_restore),
432+ style = MaterialTheme.typography.titleSmall,
433+ color = MaterialTheme.colorScheme.primary,
434+ modifier = Modifier.semantics { heading() },
437+ stringResource(R.string.backup_desc),
438+ style = MaterialTheme.typography.bodySmall,
439+ color = MaterialTheme.colorScheme.onSurfaceVariant,
440+ modifier = Modifier.padding(top = 4.dp),
446+ value = includePasswords,
447+ onValueChange = { includePasswords = it },
450+ .padding(vertical = 8.dp),
451+ verticalAlignment = Alignment.CenterVertically,
453+ Column(modifier = Modifier.weight(1f)) {
454+ Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
456+ stringResource(R.string.backup_include_passwords_desc),
457+ style = MaterialTheme.typography.bodySmall,
458+ color = MaterialTheme.colorScheme.onSurfaceVariant,
461+ Switch(checked = includePasswords, onCheckedChange = null)
464+ icon = Icons.Filled.Upload,
465+ label = stringResource(R.string.export_backup),
467+ onClick = { runCatching { exportLauncher.launch("opencode-backup.json") } },
470+ icon = Icons.Filled.Download,
471+ label = stringResource(R.string.import_backup),
475+ importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
320 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
480 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
@@ -360,6 +520,83 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
523+/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
526+ icon: androidx.compose.ui.graphics.vector.ImageVector,
529+ onClick: () -> Unit,
534+ .defaultMinSize(minHeight = 48.dp)
535+ .clickable(enabled = enabled, role = Role.Button) { onClick() }
536+ .padding(vertical = 8.dp),
537+ verticalAlignment = Alignment.CenterVertically,
541+ contentDescription = null,
542+ tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
546+ modifier = Modifier.weight(1f).padding(start = 8.dp),
547+ color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
550+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
551+ contentDescription = null,
552+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
557+/** Chat text-size slider with a live sample line, a percentage readout, and a Reset.
558+ * Drives [SettingsStore.chatTextScale]; persists only when the drag settles so a DataStore
559+ * write doesn't fire on every pixel of movement. */
561+private fun ChatTextSizeControl(scale: Float, onScaleChange: (Float) -> Unit) {
562+ // Local slider state so dragging stays smooth; re-seed when the persisted value changes
563+ // (e.g. Reset here, or an external write) so the control stays in sync.
564+ var sliderValue by remember(scale) { mutableFloatStateOf(scale) }
565+ val body = MaterialTheme.typography.bodyLarge
567+ stringResource(R.string.text_size_sample),
568+ style = body.copy(fontSize = body.fontSize * sliderValue, lineHeight = body.lineHeight * sliderValue),
569+ modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
572+ stringResource(R.string.chat_text_size_desc),
573+ style = MaterialTheme.typography.bodySmall,
574+ color = MaterialTheme.colorScheme.onSurfaceVariant,
576+ Row(verticalAlignment = Alignment.CenterVertically) {
578+ value = sliderValue,
579+ onValueChange = { sliderValue = it },
580+ onValueChangeFinished = { onScaleChange(sliderValue) },
581+ valueRange = SettingsStore.MIN_CHAT_TEXT_SCALE..SettingsStore.MAX_CHAT_TEXT_SCALE,
582+ modifier = Modifier.weight(1f),
585+ stringResource(R.string.text_scale_value, (sliderValue * 100).roundToInt()),
586+ style = MaterialTheme.typography.labelLarge,
587+ textAlign = TextAlign.End,
588+ modifier = Modifier.widthIn(min = 44.dp).padding(start = 8.dp),
593+ sliderValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE
594+ onScaleChange(SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
596+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
597+ ) { Text(stringResource(R.string.reset)) }
363@Composable
600@Composable
364private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
601private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
365 // Preview swatches so the user can see what each mode looks like before selecting,
602 // Preview swatches so the user can see what each mode looks like before selecting,