1package soy.iko.opencode.ui.settings
3import android.content.Intent
4import android.content.pm.PackageManager
6import android.provider.Settings
7import androidx.activity.compose.rememberLauncherForActivityResult
8import androidx.activity.result.contract.ActivityResultContracts
9import androidx.compose.foundation.background
10import androidx.compose.foundation.clickable
11import androidx.compose.foundation.layout.Arrangement
12import androidx.compose.foundation.layout.Box
13import androidx.compose.foundation.layout.Column
14import androidx.compose.foundation.layout.Row
15import androidx.compose.foundation.layout.Spacer
16import androidx.compose.foundation.layout.defaultMinSize
17import androidx.compose.foundation.layout.fillMaxWidth
18import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.size
20import androidx.compose.foundation.layout.widthIn
21import androidx.compose.foundation.layout.wrapContentWidth
22import androidx.compose.foundation.rememberScrollState
23import androidx.compose.foundation.selection.selectable
24import androidx.compose.foundation.selection.toggleable
25import androidx.compose.foundation.verticalScroll
26import androidx.compose.foundation.shape.CircleShape
27import androidx.compose.material.icons.Icons
28import androidx.compose.material.icons.automirrored.filled.ArrowBack
29import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
30import androidx.compose.material.icons.filled.BugReport
31import androidx.compose.material.icons.filled.Code
32import androidx.compose.material.icons.filled.Dns
33import androidx.compose.material.icons.filled.Download
34import androidx.compose.material.icons.filled.Feedback
35import androidx.compose.material.icons.filled.Hub
36import androidx.compose.material.icons.filled.Notifications
37import androidx.compose.material.icons.filled.QueryStats
38import androidx.compose.material.icons.filled.Upload
39import androidx.compose.material3.AlertDialog
40import androidx.compose.material3.ExperimentalMaterial3Api
41import androidx.compose.material3.CircularProgressIndicator
42import androidx.compose.material3.DropdownMenuItem
43import androidx.compose.material3.ExposedDropdownMenuBox
44import androidx.compose.material3.ExposedDropdownMenuDefaults
45import androidx.compose.material3.HorizontalDivider
46import androidx.compose.material3.Icon
47import androidx.compose.material3.IconButton
48import androidx.compose.material3.MaterialTheme
49import androidx.compose.material3.MenuAnchorType
50import androidx.compose.material3.OutlinedTextField
51import androidx.compose.material3.RadioButton
52import androidx.compose.material3.Scaffold
53import androidx.compose.material3.SnackbarHost
54import androidx.compose.material3.SnackbarHostState
55import androidx.compose.material3.Slider
56import androidx.compose.material3.SnackbarResult
57import androidx.compose.material3.Switch
58import androidx.compose.material3.Text
59import androidx.compose.material3.TextButton
60import androidx.compose.material3.TopAppBar
61import androidx.compose.runtime.Composable
62import androidx.compose.runtime.getValue
63import androidx.compose.runtime.mutableFloatStateOf
64import androidx.compose.runtime.mutableStateOf
65import androidx.compose.runtime.remember
66import androidx.compose.runtime.rememberCoroutineScope
67import androidx.compose.runtime.saveable.rememberSaveable
68import androidx.compose.runtime.setValue
69import androidx.compose.ui.Alignment
70import androidx.compose.ui.Modifier
71import androidx.compose.ui.draw.clip
72import androidx.compose.ui.graphics.Color
73import androidx.compose.ui.platform.LocalContext
74import androidx.compose.ui.platform.LocalDensity
75import androidx.compose.ui.res.pluralStringResource
76import androidx.compose.ui.res.stringResource
77import androidx.compose.ui.semantics.Role
78import androidx.compose.ui.semantics.heading
79import androidx.compose.ui.semantics.role
80import androidx.compose.ui.semantics.semantics
81import androidx.compose.ui.text.style.TextAlign
82import androidx.compose.ui.unit.dp
83import androidx.lifecycle.compose.LocalLifecycleOwner
84import androidx.lifecycle.compose.LifecycleResumeEffect
85import androidx.lifecycle.compose.collectAsStateWithLifecycle
86import kotlinx.coroutines.ExperimentalCoroutinesApi
87import kotlinx.coroutines.flow.combine
88import kotlinx.coroutines.flow.flatMapLatest
89import kotlinx.coroutines.flow.flowOf
90import kotlinx.coroutines.launch
91import soy.iko.opencode.data.network.EventStreamClient
92import soy.iko.opencode.data.repo.CrashLogger
93import soy.iko.opencode.data.repo.SettingsStore
94import soy.iko.opencode.data.repo.ThemeMode
95import soy.iko.opencode.di.AppContainer
96import soy.iko.opencode.R
97import soy.iko.opencode.data.network.NetworkConfig
98import soy.iko.opencode.ui.canAuthenticateForAppLock
99import soy.iko.opencode.ui.theme.LightPaletteSwatches
100import soy.iko.opencode.ui.theme.DarkPaletteSwatches
101import soy.iko.opencode.ui.theme.AmoledPaletteSwatches
102import soy.iko.opencode.util.runCatchingCancellable
103import kotlin.math.roundToInt
105@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
108 container: AppContainer,
110 onManageServers: () -> Unit,
111 onOpenDiagnostics: () -> Unit,
112 onOpenUsage: () -> Unit = {},
113 onOpenMcp: () -> Unit = {},
115 val scope = rememberCoroutineScope()
116 val snackbar = remember { SnackbarHostState() }
117 // Combine the three settings into a single nullable state so the appearance section
118 // renders only after the persisted values have loaded, avoiding a brief flash of
119 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
120 // Wrapped in remember so the combined Flow isn't re-created on every recomposition
121 // (which would cancel and relaunch the DataStore collection each time — see the
122 // connectionStateFlow below, remembered for the same reason).
123 val settingsFlow = remember(container) {
125 container.settingsStore.themeMode,
126 container.settingsStore.dynamicColor,
127 container.settingsStore.sendOnEnter,
128 container.settingsStore.appLock,
129 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
131 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
132 // Re-check biometric availability whenever the screen resumes. A user who enrolled a
133 // fingerprint in system settings then returned here would otherwise still see "unavailable"
134 // (remember with no key caches the first check forever). LifecycleResumeEffect bumps a tick
135 // on each ON_RESUME; keying the availability check on it forces a recompute.
136 val lifecycleOwner = LocalLifecycleOwner.current
137 var biometricCheckTick by remember { mutableStateOf(0) }
138 LifecycleResumeEffect(Unit, lifecycleOwner) {
142 // Text/display prefs are collected separately from [settingsFlow] (kotlinx combine
143 // tops out at 5 typed flows) and render independently — they don't need the same
144 // load-gate as the appearance block.
145 val chatTextScale by container.settingsStore.chatTextScale
146 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
147 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
148 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
149 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
150 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
151 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
152 // SSE connection state so the Settings screen can show a dropped stream (the
153 // ConnectionBanner surfaces this on the chat and session screens; without it here,
154 // a user who opens Settings during a reconnect wouldn't know the stream dropped).
155 // Wrapped in remember so the Flow operator isn't re-created every recomposition
156 // (which would reset collectAsStateWithLifecycle and cause flicker).
157 val connectionStateFlow = remember {
158 container.activeConnection
159 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
161 val connectionState by connectionStateFlow
162 .collectAsStateWithLifecycle(initialValue = EventStreamClient.ConnectionState.Disconnected)
163 val context = LocalContext.current
164 // Crash count badge: surface that there are reports to look at without making the
165 // user open the screen to find out.
166 val crashLogger = remember { CrashLogger.get(context) }
167 val crashReports by crashLogger.reports.collectAsStateWithLifecycle()
168 val crashCount = crashReports.size
169 val unknownVersion = stringResource(R.string.unknown_version)
170 val versionName = remember {
172 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
173 context.packageManager.getPackageInfo(
175 PackageManager.PackageInfoFlags.of(0),
178 @Suppress("DEPRECATION")
179 context.packageManager.getPackageInfo(context.packageName, 0).versionName
181 }.getOrNull() ?: unknownVersion
184 // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
185 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
186 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
187 var includePasswords by rememberSaveable { mutableStateOf(false) }
188 // Import overwrites all profiles/settings with no undo, so a picked file is staged here
189 // and only applied once the user confirms the replace dialog below. Export with passwords
190 // included warns first (plaintext credentials leave the app).
191 var pendingImportUri by remember { mutableStateOf<android.net.Uri?>(null) }
192 var showExportPasswordWarning by rememberSaveable { mutableStateOf(false) }
193 val exportedMsg = stringResource(R.string.backup_exported)
194 val exportFailedMsg = stringResource(R.string.backup_export_failed)
195 val importedMsg = stringResource(R.string.backup_imported)
196 val restartLabel = stringResource(R.string.restart)
197 val couldNotOpenLinkMsg = stringResource(R.string.could_not_open_link)
198 val importFailedMsg = stringResource(R.string.backup_import_failed)
199 val exportLauncher = rememberLauncherForActivityResult(
200 ActivityResultContracts.CreateDocument("application/json"),
202 if (uri == null) return@rememberLauncherForActivityResult
204 val ok = runCatchingCancellable {
205 val text = container.backupManager.export(includePasswords)
206 context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
207 ?: error("no output stream")
209 snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
212 val importLauncher = rememberLauncherForActivityResult(
213 ActivityResultContracts.OpenDocument(),
215 if (uri == null) return@rememberLauncherForActivityResult
216 // Stage the file; the actual destructive import waits on the confirm dialog.
217 pendingImportUri = uri
219 // Timestamped export name so successive backups don't overwrite one another when the user
220 // exports more than once (e.g. before and after a change).
221 fun backupFilename(): String {
222 val now = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US)
223 .format(java.util.Date())
224 return "opencode-backup-$now.json"
230 title = { Text(stringResource(R.string.settings)) },
232 IconButton(onClick = onBack) {
233 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
238 snackbarHost = { SnackbarHost(snackbar) },
244 .wrapContentWidth(Alignment.CenterHorizontally)
245 .widthIn(max = 600.dp)
246 .verticalScroll(rememberScrollState())
251 stringResource(R.string.appearance),
252 style = MaterialTheme.typography.titleSmall,
253 color = MaterialTheme.colorScheme.primary,
254 modifier = Modifier.semantics { heading() },
257 ThemeMode.entries.forEach { mode ->
260 selected = s.themeMode == mode,
261 // When Material You is active the static Tokyo Night swatches would be a
262 // lie, so ThemeRow hides them and notes that system colors are in use.
263 usingSystemColors = s.dynamicColor && dynamicColorAvailable,
264 onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
268 Spacer(Modifier.size(8.dp))
269 // Dynamic color (Material You) only works on Android 12+, so hide the toggle
270 // on older devices instead of offering a control that silently does nothing.
271 if (dynamicColorAvailable) {
272 val amoledActive = s.themeMode == ThemeMode.AMOLED
277 value = s.dynamicColor,
278 enabled = !amoledActive,
279 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
282 .padding(vertical = 8.dp),
283 verticalAlignment = Alignment.CenterVertically,
285 Column(modifier = Modifier.weight(1f)) {
286 Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
288 stringResource(R.string.dynamic_color_desc),
289 style = MaterialTheme.typography.bodySmall,
290 color = MaterialTheme.colorScheme.onSurfaceVariant,
294 stringResource(R.string.dynamic_color_disabled_amoled),
295 style = MaterialTheme.typography.bodySmall,
296 color = MaterialTheme.colorScheme.onSurfaceVariant,
301 checked = s.dynamicColor,
302 onCheckedChange = null,
303 enabled = !amoledActive,
308 // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
309 Spacer(Modifier.size(4.dp))
314 value = s.sendOnEnter,
315 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
318 .padding(vertical = 8.dp),
319 verticalAlignment = Alignment.CenterVertically,
321 Column(modifier = Modifier.weight(1f)) {
322 Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
324 stringResource(R.string.send_on_enter_desc),
325 style = MaterialTheme.typography.bodySmall,
326 color = MaterialTheme.colorScheme.onSurfaceVariant,
330 checked = s.sendOnEnter,
331 onCheckedChange = null,
335 // App lock: gate on device capability. Always allow turning it *off* (in case
336 // the enrolled biometric was later removed), but only allow turning it on when
337 // a biometric or device credential is actually available.
338 val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
339 val appLockToggleable = appLockAvailable || s.appLock
340 Spacer(Modifier.size(4.dp))
346 enabled = appLockToggleable,
347 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
350 .padding(vertical = 8.dp),
351 verticalAlignment = Alignment.CenterVertically,
353 Column(modifier = Modifier.weight(1f)) {
354 Text(stringResource(R.string.app_lock), style = MaterialTheme.typography.bodyLarge)
356 stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
357 style = MaterialTheme.typography.bodySmall,
358 color = MaterialTheme.colorScheme.onSurfaceVariant,
363 onCheckedChange = null,
364 enabled = appLockToggleable,
368 AppLockReLockSelector(
369 seconds = appLockReLockSeconds,
371 scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
377 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
378 contentAlignment = Alignment.Center,
380 CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
384 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
387 stringResource(R.string.text_display),
388 style = MaterialTheme.typography.titleSmall,
389 color = MaterialTheme.colorScheme.primary,
390 modifier = Modifier.semantics { heading() },
393 scale = chatTextScale,
394 onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
396 Spacer(Modifier.size(4.dp))
402 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
405 .padding(vertical = 8.dp),
406 verticalAlignment = Alignment.CenterVertically,
408 Column(modifier = Modifier.weight(1f)) {
409 Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
411 stringResource(R.string.wrap_code_blocks_desc),
412 style = MaterialTheme.typography.bodySmall,
413 color = MaterialTheme.colorScheme.onSurfaceVariant,
416 Switch(checked = codeWrap, onCheckedChange = null)
419 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
422 stringResource(R.string.connection),
423 style = MaterialTheme.typography.titleSmall,
424 color = MaterialTheme.colorScheme.primary,
425 modifier = Modifier.semantics { heading() },
427 if (activeProfile != null) {
428 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
430 activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
431 style = MaterialTheme.typography.bodySmall,
432 color = MaterialTheme.colorScheme.onSurfaceVariant,
434 // Surface the SSE stream state so a user who opens Settings during a
435 // reconnect saw it (the ConnectionBanner on other screens handles this
436 // elsewhere). Only show non-Connected states to avoid clutter. On a hard
437 // failure offer an inline Retry — without it a user who opened Settings
438 // during a failure is stuck on an error message with no recovery path
439 // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
440 // host it). Retry forces an SSE reconnect, which re-seeds from REST.
441 val stateText = when (connectionState) {
442 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
443 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
444 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
445 EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
446 EventStreamClient.ConnectionState.Connected -> null
450 modifier = Modifier.padding(top = 4.dp),
451 verticalAlignment = Alignment.CenterVertically,
453 val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
454 connectionState == EventStreamClient.ConnectionState.AuthFailed
457 style = MaterialTheme.typography.bodySmall,
458 color = if (isHardFailure)
459 MaterialTheme.colorScheme.error
460 else MaterialTheme.colorScheme.onSurfaceVariant,
464 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
465 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
466 ) { Text(stringResource(R.string.retry_now)) }
472 stringResource(R.string.not_connected),
473 modifier = Modifier.padding(top = 8.dp),
474 color = MaterialTheme.colorScheme.onSurfaceVariant,
480 .defaultMinSize(minHeight = 48.dp)
482 .clickable(role = Role.Button) { onManageServers() }
483 .padding(vertical = 8.dp),
484 verticalAlignment = Alignment.CenterVertically,
486 Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
487 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
488 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
491 icon = Icons.Filled.QueryStats,
492 label = stringResource(R.string.usage_title),
493 enabled = activeProfile != null,
494 onClick = onOpenUsage,
497 icon = Icons.Filled.Hub,
498 label = stringResource(R.string.mcp_servers),
499 enabled = activeProfile != null,
503 icon = Icons.Filled.Notifications,
504 label = stringResource(R.string.notification_settings),
508 val opened = runCatchingCancellable {
509 val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
510 .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
511 context.startActivity(intent)
513 if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
518 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
521 stringResource(R.string.backup_restore),
522 style = MaterialTheme.typography.titleSmall,
523 color = MaterialTheme.colorScheme.primary,
524 modifier = Modifier.semantics { heading() },
527 stringResource(R.string.backup_desc),
528 style = MaterialTheme.typography.bodySmall,
529 color = MaterialTheme.colorScheme.onSurfaceVariant,
530 modifier = Modifier.padding(top = 4.dp),
536 value = includePasswords,
537 onValueChange = { includePasswords = it },
540 .padding(vertical = 8.dp),
541 verticalAlignment = Alignment.CenterVertically,
543 Column(modifier = Modifier.weight(1f)) {
544 Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
546 stringResource(R.string.backup_include_passwords_desc),
547 style = MaterialTheme.typography.bodySmall,
548 color = MaterialTheme.colorScheme.onSurfaceVariant,
551 Switch(checked = includePasswords, onCheckedChange = null)
554 icon = Icons.Filled.Upload,
555 label = stringResource(R.string.export_backup),
557 // Warn before writing plaintext credentials out of the app; confirm then launches
558 // the file picker. Without passwords, export straight away.
560 if (includePasswords) showExportPasswordWarning = true
561 else runCatching { exportLauncher.launch(backupFilename()) }
565 icon = Icons.Filled.Download,
566 label = stringResource(R.string.import_backup),
570 importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
575 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
578 stringResource(R.string.about),
579 style = MaterialTheme.typography.titleSmall,
580 color = MaterialTheme.colorScheme.primary,
581 modifier = Modifier.semantics { heading() },
584 stringResource(R.string.about_version, versionName),
585 style = MaterialTheme.typography.bodyMedium,
586 modifier = Modifier.padding(top = 8.dp),
589 stringResource(R.string.about_desc),
590 style = MaterialTheme.typography.bodySmall,
591 color = MaterialTheme.colorScheme.onSurfaceVariant,
592 modifier = Modifier.padding(top = 4.dp),
595 Spacer(Modifier.size(16.dp))
599 .defaultMinSize(minHeight = 48.dp)
600 .clickable(role = Role.Button) { onOpenDiagnostics() }
601 .padding(vertical = 8.dp),
602 verticalAlignment = Alignment.CenterVertically,
604 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
605 Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
606 // Badge with the crash count so the user can tell at a glance whether
607 // there's something worth investigating.
608 if (crashCount > 0) {
609 Badge(count = crashCount)
610 Spacer(Modifier.size(8.dp))
612 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
614 // Discoverability links from the About section: source code and issue tracker.
615 // Opens in the browser via ACTION_VIEW; if nothing can handle it, the chooser just
616 // shows no options (a no-op), which is acceptable for these optional links.
618 icon = Icons.Filled.Code,
619 label = stringResource(R.string.source_code),
623 context.startActivity(
624 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
630 icon = Icons.Filled.Feedback,
631 label = stringResource(R.string.report_issue),
635 context.startActivity(
636 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
644 // Destructive-import confirmation: applies the staged file only on Replace.
645 pendingImportUri?.let { uri ->
647 onDismissRequest = { pendingImportUri = null },
648 title = { Text(stringResource(R.string.import_confirm_title)) },
649 text = { Text(stringResource(R.string.import_confirm_text)) },
651 TextButton(onClick = {
652 pendingImportUri = null
654 val ok = runCatchingCancellable {
655 val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
656 ?: error("no input stream")
657 container.backupManager.import(text)
660 val result = snackbar.showSnackbar(
661 message = importedMsg,
662 actionLabel = restartLabel,
664 if (result == SnackbarResult.ActionPerformed) {
665 android.os.Process.killProcess(android.os.Process.myPid())
668 snackbar.showSnackbar(importFailedMsg)
671 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
674 TextButton(onClick = { pendingImportUri = null }) { Text(stringResource(R.string.cancel)) }
679 // Plaintext-password export warning: only launches the file picker on confirm.
680 if (showExportPasswordWarning) {
682 onDismissRequest = { showExportPasswordWarning = false },
683 title = { Text(stringResource(R.string.export_passwords_title)) },
684 text = { Text(stringResource(R.string.export_passwords_warning)) },
686 TextButton(onClick = {
687 showExportPasswordWarning = false
688 runCatching { exportLauncher.launch(backupFilename()) }
689 }) { Text(stringResource(R.string.export_backup), color = MaterialTheme.colorScheme.error) }
692 TextButton(onClick = { showExportPasswordWarning = false }) { Text(stringResource(R.string.cancel)) }
698/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
701 icon: androidx.compose.ui.graphics.vector.ImageVector,
709 .defaultMinSize(minHeight = 48.dp)
710 .clickable(enabled = enabled, role = Role.Button) { onClick() }
711 .padding(vertical = 8.dp),
712 verticalAlignment = Alignment.CenterVertically,
716 contentDescription = null,
717 tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
721 modifier = Modifier.weight(1f).padding(start = 8.dp),
722 color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
725 Icons.AutoMirrored.Filled.KeyboardArrowRight,
726 contentDescription = null,
727 tint = MaterialTheme.colorScheme.onSurfaceVariant,
732/** Chat text-size slider with a live sample line, a percentage readout, and a Reset.
733 * Drives [SettingsStore.chatTextScale]; persists only when the drag settles so a DataStore
734 * write doesn't fire on every pixel of movement. */
736private fun ChatTextSizeControl(scale: Float, onScaleChange: (Float) -> Unit) {
737 // Local slider state so dragging stays smooth; re-seed when the persisted value changes
738 // (e.g. Reset here, or an external write) so the control stays in sync.
739 var sliderValue by remember(scale) { mutableFloatStateOf(scale) }
740 val body = MaterialTheme.typography.bodyLarge
742 stringResource(R.string.text_size_sample),
743 style = body.copy(fontSize = body.fontSize * sliderValue, lineHeight = body.lineHeight * sliderValue),
744 modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
747 stringResource(R.string.chat_text_size_desc),
748 style = MaterialTheme.typography.bodySmall,
749 color = MaterialTheme.colorScheme.onSurfaceVariant,
751 Row(verticalAlignment = Alignment.CenterVertically) {
754 onValueChange = { sliderValue = it },
755 onValueChangeFinished = { onScaleChange(sliderValue) },
756 valueRange = SettingsStore.MIN_CHAT_TEXT_SCALE..SettingsStore.MAX_CHAT_TEXT_SCALE,
757 modifier = Modifier.weight(1f),
760 stringResource(R.string.text_scale_value, (sliderValue * 100).roundToInt()),
761 style = MaterialTheme.typography.labelLarge,
762 textAlign = TextAlign.End,
763 modifier = Modifier.widthIn(min = 44.dp).padding(start = 8.dp),
768 sliderValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE
769 onScaleChange(SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
771 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
772 ) { Text(stringResource(R.string.reset)) }
773 // The app text scale multiplies on top of the OS font scale, and the effective size is
774 // clamped so the product can't exceed the combined cap (see OpencodeApp). When the user's
775 // system font is already large, the value they pick here would be silently reduced — so
776 // surface that the effective size is capped rather than letting the slider appear broken.
777 val systemFontScale = LocalDensity.current.fontScale
778 if (systemFontScale > 0f) {
779 val effective = (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
780 if (effective < sliderValue) {
782 stringResource(R.string.text_size_capped, (effective * 100).roundToInt()),
783 style = MaterialTheme.typography.bodySmall,
784 color = MaterialTheme.colorScheme.onSurfaceVariant,
785 modifier = Modifier.padding(top = 2.dp),
792private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Boolean, onSelect: () -> Unit) {
793 // Preview swatches so the user can see what each mode looks like before selecting,
794 // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
795 // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
796 val swatches: List<Color> = when (mode) {
797 ThemeMode.SYSTEM -> {
798 val dark = isSystemInDarkThemeStatic()
799 if (dark) DarkSwatches else LightSwatches
801 ThemeMode.LIGHT -> LightSwatches
802 ThemeMode.DARK -> DarkSwatches
803 ThemeMode.AMOLED -> AmoledSwatches
808 .selectable(selected = selected, onClick = onSelect)
809 .padding(vertical = 8.dp),
810 horizontalArrangement = Arrangement.spacedBy(8.dp),
811 verticalAlignment = Alignment.CenterVertically,
813 RadioButton(selected = selected, onClick = null)
816 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
817 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
818 ThemeMode.DARK -> stringResource(R.string.theme_dark)
819 ThemeMode.AMOLED -> stringResource(R.string.theme_amoled)
821 style = MaterialTheme.typography.bodyLarge,
822 modifier = Modifier.weight(1f),
824 if (usingSystemColors) {
825 // Dynamic color (Material You) overrides the static palettes, so the dots would
826 // misrepresent the actual colors — note that system colors are in use instead.
828 stringResource(R.string.theme_using_system_colors),
829 style = MaterialTheme.typography.labelSmall,
830 color = MaterialTheme.colorScheme.onSurfaceVariant,
831 textAlign = TextAlign.End,
834 // Three small color dots previewing the palette for this mode.
835 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
836 swatches.forEach { color ->
849// Palette preview dots for the theme rows. Primary/secondary/tertiary/background convey
850// the character of each scheme at a glance without rendering a full sample surface.
851private val DarkSwatches: List<Color> = DarkPaletteSwatches
852private val LightSwatches: List<Color> = LightPaletteSwatches
853private val AmoledSwatches: List<Color> = AmoledPaletteSwatches
855// Composable-side read of the system dark setting for the "System" theme row preview.
856// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
857// helper; hoist it here so the ThemeRow stays a plain @Composable with no preview-time
858// recomposition surprises.
860private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
862/** Small wrapper so the three appearance settings load atomically and the UI can gate
863 * on a single null check instead of flashing hardcoded defaults. */
864private data class SettingsValues(
865 val themeMode: ThemeMode,
866 val dynamicColor: Boolean,
867 val sendOnEnter: Boolean,
868 val appLock: Boolean,
871/** Small circular count badge used to indicate pending crash reports. */
873private fun Badge(count: Int) {
874 val overflow = stringResource(R.string.crash_count_overflow)
879 .background(MaterialTheme.colorScheme.error),
880 contentAlignment = Alignment.Center,
883 if (count > 99) overflow else count.toString(),
884 style = MaterialTheme.typography.labelSmall,
885 color = MaterialTheme.colorScheme.onError,
886 textAlign = TextAlign.Center,
891/** Dropdown selecting how long after backgrounding the app re-locks. Only shown when app lock
892 * is on. The grace period avoids a re-prompt on every quick app-switch (the most common reason
893 * users disable biometric locks), while still re-locking immediately by default. */
894@OptIn(ExperimentalMaterial3Api::class)
896private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
897 var expanded by remember { mutableStateOf(false) }
898 ExposedDropdownMenuBox(
900 onExpandedChange = { expanded = it },
903 .padding(start = 56.dp, top = 4.dp),
906 value = relockLabel(seconds),
910 label = { Text(stringResource(R.string.app_lock_relock)) },
911 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
913 .menuAnchor(MenuAnchorType.PrimaryNotEditable, true)
918 onDismissRequest = { expanded = false },
920 SettingsStore.APP_LOCK_RELOCK_OPTIONS_SECONDS.forEach { option ->
922 text = { Text(relockLabel(option)) },
923 onClick = { expanded = false; onSelect(option) },
931private fun relockLabel(seconds: Int): String = when {
932 seconds <= 0 -> stringResource(R.string.app_lock_relock_immediately)
933 seconds < 60 -> pluralStringResource(R.plurals.app_lock_relock_seconds, seconds, seconds)
935 val mins = seconds / 60
936 pluralStringResource(R.plurals.app_lock_relock_minutes, mins, mins)
939 val hours = seconds / 3600
940 pluralStringResource(R.plurals.app_lock_relock_hours, hours, hours)
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