@@ -2,6 +2,8 @@ package soy.iko.opencode.ui.settings
22
33import android.content.pm.PackageManager
44import android.os.Build
5+import androidx.activity.compose.rememberLauncherForActivityResult
6+import androidx.activity.result.contract.ActivityResultContracts
57import androidx.compose.foundation.background
68import androidx.compose.foundation.clickable
79import androidx.compose.foundation.layout.Arrangement
@@ -24,6 +26,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
2426import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
2527import androidx.compose.material.icons.filled.BugReport
2628import 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
2733import androidx.compose.material3.ExperimentalMaterial3Api
2834import androidx.compose.material3.CircularProgressIndicator
2935import androidx.compose.material3.HorizontalDivider
@@ -32,14 +38,19 @@ import androidx.compose.material3.IconButton
3238import androidx.compose.material3.MaterialTheme
3339import androidx.compose.material3.RadioButton
3440import androidx.compose.material3.Scaffold
41+import androidx.compose.material3.Slider
3542import androidx.compose.material3.Switch
3643import androidx.compose.material3.Text
3744import androidx.compose.material3.TextButton
3845import androidx.compose.material3.TopAppBar
3946import androidx.compose.runtime.Composable
4047import androidx.compose.runtime.getValue
48+import androidx.compose.runtime.mutableFloatStateOf
49+import androidx.compose.runtime.mutableStateOf
4150import androidx.compose.runtime.remember
4251import androidx.compose.runtime.rememberCoroutineScope
52+import androidx.compose.runtime.saveable.rememberSaveable
53+import androidx.compose.runtime.setValue
4354import androidx.compose.ui.Alignment
4455import androidx.compose.ui.Modifier
4556import androidx.compose.ui.draw.clip
@@ -60,17 +71,27 @@ import kotlinx.coroutines.flow.flowOf
6071import kotlinx.coroutines.launch
6172import soy.iko.opencode.data.network.EventStreamClient
6273import soy.iko.opencode.data.repo.CrashLogger
74+import soy.iko.opencode.data.repo.SettingsStore
6375import soy.iko.opencode.data.repo.ThemeMode
6476import soy.iko.opencode.di.AppContainer
6577import soy.iko.opencode.R
6678import soy.iko.opencode.ui.canAuthenticateForAppLock
79+import soy.iko.opencode.ui.components.showToast
6780import soy.iko.opencode.ui.theme.LightPaletteSwatches
6881import soy.iko.opencode.ui.theme.DarkPaletteSwatches
6982import soy.iko.opencode.util.runCatchingCancellable
83+import kotlin.math.roundToInt
7084
7185@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
7286@Composable
73−fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
87+fun SettingsScreen(
88+ container: AppContainer,
89+ onBack: () -> Unit,
90+ onManageServers: () -> Unit,
91+ onOpenDiagnostics: () -> Unit,
92+ onOpenUsage: () -> Unit = {},
93+ onOpenMcp: () -> Unit = {},
94+) {
7495 val scope = rememberCoroutineScope()
7596 // Combine the three settings into a single nullable state so the appearance section
7697 // renders only after the persisted values have loaded, avoiding a brief flash of
@@ -87,6 +108,12 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
87108 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
88109 }
89110 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)
90117 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
91118 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
92119 // SSE connection state so the Settings screen can show a dropped stream (the
@@ -121,6 +148,40 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
121148 }.getOrNull() ?: unknownVersion
122149 }
123150
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"),
160+ ) { uri ->
161+ if (uri == null) return@rememberLauncherForActivityResult
162+ scope.launch {
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")
167+ }.isSuccess
168+ showToast(context, if (ok) exportedMsg else exportFailedMsg)
169+ }
170+ }
171+ val importLauncher = rememberLauncherForActivityResult(
172+ ActivityResultContracts.OpenDocument(),
173+ ) { uri ->
174+ if (uri == null) return@rememberLauncherForActivityResult
175+ scope.launch {
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)
180+ }.isSuccess
181+ showToast(context, if (ok) importedMsg else importFailedMsg)
182+ }
183+ }
184+
124185 Scaffold(
125186 topBar = {
126187 TopAppBar(
@@ -250,6 +311,41 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
250311
251312 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
252313
314+ Text(
315+ stringResource(R.string.text_display),
316+ style = MaterialTheme.typography.titleSmall,
317+ color = MaterialTheme.colorScheme.primary,
318+ modifier = Modifier.semantics { heading() },
319+ )
320+ ChatTextSizeControl(
321+ scale = chatTextScale,
322+ onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
323+ )
324+ Spacer(Modifier.size(4.dp))
325+ Row(
326+ modifier = Modifier
327+ .fillMaxWidth()
328+ .toggleable(
329+ value = codeWrap,
330+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
331+ role = Role.Switch,
332+ )
333+ .padding(vertical = 8.dp),
334+ verticalAlignment = Alignment.CenterVertically,
335+ ) {
336+ Column(modifier = Modifier.weight(1f)) {
337+ Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
338+ Text(
339+ stringResource(R.string.wrap_code_blocks_desc),
340+ style = MaterialTheme.typography.bodySmall,
341+ color = MaterialTheme.colorScheme.onSurfaceVariant,
342+ )
343+ }
344+ Switch(checked = codeWrap, onCheckedChange = null)
345+ }
346+
347+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
348+
253349 Text(
254350 stringResource(R.string.connection),
255351 style = MaterialTheme.typography.titleSmall,
@@ -316,6 +412,70 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
316412 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
317413 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
318414 }
415+ NavRow(
416+ icon = Icons.Filled.QueryStats,
417+ label = stringResource(R.string.usage_title),
418+ enabled = activeProfile != null,
419+ onClick = onOpenUsage,
420+ )
421+ NavRow(
422+ icon = Icons.Filled.Hub,
423+ label = stringResource(R.string.mcp_servers),
424+ enabled = activeProfile != null,
425+ onClick = onOpenMcp,
426+ )
427+
428+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
429+
430+ Text(
431+ stringResource(R.string.backup_restore),
432+ style = MaterialTheme.typography.titleSmall,
433+ color = MaterialTheme.colorScheme.primary,
434+ modifier = Modifier.semantics { heading() },
435+ )
436+ Text(
437+ stringResource(R.string.backup_desc),
438+ style = MaterialTheme.typography.bodySmall,
439+ color = MaterialTheme.colorScheme.onSurfaceVariant,
440+ modifier = Modifier.padding(top = 4.dp),
441+ )
442+ Row(
443+ modifier = Modifier
444+ .fillMaxWidth()
445+ .toggleable(
446+ value = includePasswords,
447+ onValueChange = { includePasswords = it },
448+ role = Role.Switch,
449+ )
450+ .padding(vertical = 8.dp),
451+ verticalAlignment = Alignment.CenterVertically,
452+ ) {
453+ Column(modifier = Modifier.weight(1f)) {
454+ Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
455+ Text(
456+ stringResource(R.string.backup_include_passwords_desc),
457+ style = MaterialTheme.typography.bodySmall,
458+ color = MaterialTheme.colorScheme.onSurfaceVariant,
459+ )
460+ }
461+ Switch(checked = includePasswords, onCheckedChange = null)
462+ }
463+ NavRow(
464+ icon = Icons.Filled.Upload,
465+ label = stringResource(R.string.export_backup),
466+ enabled = true,
467+ onClick = { runCatching { exportLauncher.launch("opencode-backup.json") } },
468+ )
469+ NavRow(
470+ icon = Icons.Filled.Download,
471+ label = stringResource(R.string.import_backup),
472+ enabled = true,
473+ onClick = {
474+ runCatching {
475+ importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
476+ }
477+ },
478+ )
319479
320480 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
321481
@@ -360,6 +520,83 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
360520 }
361521}
362522
523+/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
524+@Composable
525+private fun NavRow(
526+ icon: androidx.compose.ui.graphics.vector.ImageVector,
527+ label: String,
528+ enabled: Boolean,
529+ onClick: () -> Unit,
530+) {
531+ Row(
532+ modifier = Modifier
533+ .fillMaxWidth()
534+ .defaultMinSize(minHeight = 48.dp)
535+ .clickable(enabled = enabled, role = Role.Button) { onClick() }
536+ .padding(vertical = 8.dp),
537+ verticalAlignment = Alignment.CenterVertically,
538+ ) {
539+ Icon(
540+ icon,
541+ contentDescription = null,
542+ tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
543+ )
544+ Text(
545+ label,
546+ modifier = Modifier.weight(1f).padding(start = 8.dp),
547+ color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
548+ )
549+ Icon(
550+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
551+ contentDescription = null,
552+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
553+ )
554+ }
555+}
556+
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. */
560+@Composable
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
566+ Text(
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),
570+ )
571+ Text(
572+ stringResource(R.string.chat_text_size_desc),
573+ style = MaterialTheme.typography.bodySmall,
574+ color = MaterialTheme.colorScheme.onSurfaceVariant,
575+ )
576+ Row(verticalAlignment = Alignment.CenterVertically) {
577+ Slider(
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),
583+ )
584+ Text(
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),
589+ )
590+ }
591+ TextButton(
592+ onClick = {
593+ sliderValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE
594+ onScaleChange(SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
595+ },
596+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
597+ ) { Text(stringResource(R.string.reset)) }
598+}
599+
363600@Composable
364601private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
365602 // Preview swatches so the user can see what each mode looks like before selecting,