git.iko.soy/opencode-android
on 61cdc75 help sign in
1005 lines 49 kB history raw
1package soy.iko.opencode.ui.settings
3import android.content.Intent
4import android.content.pm.PackageManager
5import android.os.Build
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.LinearProgressIndicator
49import androidx.compose.material3.MaterialTheme
50import androidx.compose.material3.MenuAnchorType
51import androidx.compose.material3.OutlinedTextField
52import androidx.compose.material3.RadioButton
53import androidx.compose.material3.Scaffold
54import androidx.compose.material3.SnackbarHost
55import androidx.compose.material3.SnackbarHostState
56import androidx.compose.material3.Slider
57import androidx.compose.material3.SnackbarResult
58import androidx.compose.material3.Switch
59import androidx.compose.material3.Text
60import androidx.compose.material3.TextButton
61import androidx.compose.material3.TopAppBar
62import androidx.compose.runtime.Composable
63import androidx.compose.runtime.getValue
64import androidx.compose.runtime.mutableFloatStateOf
65import androidx.compose.runtime.mutableStateOf
66import androidx.compose.runtime.remember
67import androidx.compose.runtime.rememberCoroutineScope
68import androidx.compose.runtime.saveable.rememberSaveable
69import androidx.compose.runtime.setValue
70import androidx.compose.ui.Alignment
71import androidx.compose.ui.Modifier
72import androidx.compose.ui.draw.clip
73import androidx.compose.ui.graphics.Color
74import androidx.compose.ui.platform.LocalContext
75import androidx.compose.ui.platform.LocalDensity
76import androidx.compose.ui.res.pluralStringResource
77import androidx.compose.ui.res.stringResource
78import androidx.compose.ui.semantics.Role
79import androidx.compose.ui.semantics.heading
80import androidx.compose.ui.semantics.role
81import androidx.compose.ui.semantics.semantics
82import androidx.compose.ui.text.style.TextAlign
83import androidx.compose.ui.unit.dp
84import androidx.lifecycle.compose.LocalLifecycleOwner
85import androidx.lifecycle.compose.LifecycleResumeEffect
86import androidx.lifecycle.compose.collectAsStateWithLifecycle
87import kotlinx.coroutines.ExperimentalCoroutinesApi
88import kotlinx.coroutines.flow.combine
89import kotlinx.coroutines.flow.flatMapLatest
90import kotlinx.coroutines.flow.flowOf
91import kotlinx.coroutines.launch
92import soy.iko.opencode.data.network.EventStreamClient
93import soy.iko.opencode.data.repo.CrashLogger
94import soy.iko.opencode.data.repo.SettingsStore
95import soy.iko.opencode.data.repo.ThemeMode
96import soy.iko.opencode.di.AppContainer
97import soy.iko.opencode.R
98import soy.iko.opencode.data.network.NetworkConfig
99import soy.iko.opencode.ui.canAuthenticateForAppLock
100import soy.iko.opencode.ui.theme.LightPaletteSwatches
101import soy.iko.opencode.ui.theme.DarkPaletteSwatches
102import soy.iko.opencode.ui.theme.AmoledPaletteSwatches
103import soy.iko.opencode.util.runCatchingCancellable
104import kotlin.math.roundToInt
106@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
107@Composable
108fun SettingsScreen(
109 container: AppContainer,
110 onBack: () -> Unit,
111 onManageServers: () -> Unit,
112 onOpenDiagnostics: () -> Unit,
113 onOpenUsage: () -> Unit = {},
114 onOpenMcp: () -> Unit = {},
115) {
116 val scope = rememberCoroutineScope()
117 val snackbar = remember { SnackbarHostState() }
118 // Combine the three settings into a single nullable state so the appearance section
119 // renders only after the persisted values have loaded, avoiding a brief flash of
120 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
121 // Wrapped in remember so the combined Flow isn't re-created on every recomposition
122 // (which would cancel and relaunch the DataStore collection each time — see the
123 // connectionStateFlow below, remembered for the same reason).
124 val settingsFlow = remember(container) {
125 combine(
126 container.settingsStore.themeMode,
127 container.settingsStore.dynamicColor,
128 container.settingsStore.sendOnEnter,
129 container.settingsStore.appLock,
130 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
131 }
132 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
133 // Re-check biometric availability whenever the screen resumes. A user who enrolled a
134 // fingerprint in system settings then returned here would otherwise still see "unavailable"
135 // (remember with no key caches the first check forever). LifecycleResumeEffect bumps a tick
136 // on each ON_RESUME; keying the availability check on it forces a recompute.
137 val lifecycleOwner = LocalLifecycleOwner.current
138 var biometricCheckTick by remember { mutableStateOf(0) }
139 LifecycleResumeEffect(Unit, lifecycleOwner) {
140 biometricCheckTick++
141 onPauseOrDispose { }
142 }
143 // Text/display prefs are collected separately from [settingsFlow] (kotlinx combine
144 // tops out at 5 typed flows) and render independently — they don't need the same
145 // load-gate as the appearance block.
146 val chatTextScale by container.settingsStore.chatTextScale
147 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
148 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
149 val compactSpacing by container.settingsStore.compactMessageSpacing
150 .collectAsStateWithLifecycle(initialValue = false)
151 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
152 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
153 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
154 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
155 // SSE connection state so the Settings screen can show a dropped stream (the
156 // ConnectionBanner surfaces this on the chat and session screens; without it here,
157 // a user who opens Settings during a reconnect wouldn't know the stream dropped).
158 // Wrapped in remember so the Flow operator isn't re-created every recomposition
159 // (which would reset collectAsStateWithLifecycle and cause flicker).
160 val connectionStateFlow = remember {
161 container.activeConnection
162 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
163 }
164 val connectionState by connectionStateFlow
165 .collectAsStateWithLifecycle(initialValue = EventStreamClient.ConnectionState.Disconnected)
166 val context = LocalContext.current
167 // Crash count badge: surface that there are reports to look at without making the
168 // user open the screen to find out.
169 val crashLogger = remember { CrashLogger.get(context) }
170 val crashReports by crashLogger.reports.collectAsStateWithLifecycle()
171 val crashCount = crashReports.size
172 val unknownVersion = stringResource(R.string.unknown_version)
173 val versionName = remember {
174 runCatching {
175 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
176 context.packageManager.getPackageInfo(
177 context.packageName,
178 PackageManager.PackageInfoFlags.of(0),
179 ).versionName
180 } else {
181 @Suppress("DEPRECATION")
182 context.packageManager.getPackageInfo(context.packageName, 0).versionName
183 }
184 }.getOrNull() ?: unknownVersion
185 }
187 // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
188 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
189 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
190 var includePasswords by rememberSaveable { mutableStateOf(false) }
191 // True while an export or import is in flight. Used to disable both NavRow triggers so a
192 // double-tap (or a slow device) can't launch a second file picker or start a second
193 // operation while the first is still reading/writing. Mirrors the busy-flag pattern used
194 // by other slow actions in the app.
195 var backupBusy by remember { mutableStateOf(false) }
196 // Import overwrites all profiles/settings with no undo, so a picked file is staged here
197 // and only applied once the user confirms the replace dialog below. Export with passwords
198 // included warns first (plaintext credentials leave the app).
199 var pendingImportUri by remember { mutableStateOf<android.net.Uri?>(null) }
200 var showExportPasswordWarning by rememberSaveable { mutableStateOf(false) }
201 val exportedMsg = stringResource(R.string.backup_exported)
202 val exportFailedMsg = stringResource(R.string.backup_export_failed)
203 val importedMsg = stringResource(R.string.backup_imported)
204 val restartLabel = stringResource(R.string.restart)
205 val couldNotOpenLinkMsg = stringResource(R.string.could_not_open_link)
206 val importFailedMsg = stringResource(R.string.backup_import_failed)
207 val exportLauncher = rememberLauncherForActivityResult(
208 ActivityResultContracts.CreateDocument("application/json"),
209 ) { uri ->
210 if (uri == null) return@rememberLauncherForActivityResult
211 scope.launch {
212 backupBusy = true
213 try {
214 val ok = runCatchingCancellable {
215 val text = container.backupManager.export(includePasswords)
216 context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
217 ?: error("no output stream")
218 }.isSuccess
219 snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
220 } finally {
221 backupBusy = false
222 }
223 }
224 }
225 val importLauncher = rememberLauncherForActivityResult(
226 ActivityResultContracts.OpenDocument(),
227 ) { uri ->
228 if (uri == null) return@rememberLauncherForActivityResult
229 // Stage the file; the actual destructive import waits on the confirm dialog.
230 pendingImportUri = uri
231 }
232 // Timestamped export name so successive backups don't overwrite one another when the user
233 // exports more than once (e.g. before and after a change).
234 fun backupFilename(): String {
235 val now = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US)
236 .format(java.util.Date())
237 return "opencode-backup-$now.json"
238 }
240 Scaffold(
241 topBar = {
242 TopAppBar(
243 title = { Text(stringResource(R.string.settings)) },
244 navigationIcon = {
245 IconButton(onClick = onBack) {
246 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
247 }
248 },
249 )
250 },
251 snackbarHost = { SnackbarHost(snackbar) },
252 ) { padding ->
253 Column(
254 modifier = Modifier
255 .padding(padding)
256 .fillMaxWidth()
257 .wrapContentWidth(Alignment.CenterHorizontally)
258 .widthIn(max = 600.dp)
259 .verticalScroll(rememberScrollState())
260 .padding(16.dp),
261 ) {
262 val s = settings
263 Text(
264 stringResource(R.string.appearance),
265 style = MaterialTheme.typography.titleSmall,
266 color = MaterialTheme.colorScheme.primary,
267 modifier = Modifier.semantics { heading() },
268 )
269 if (s != null) {
270 ThemeMode.entries.forEach { mode ->
271 ThemeRow(
272 mode = mode,
273 selected = s.themeMode == mode,
274 // When Material You is active the static Tokyo Night swatches would be a
275 // lie, so ThemeRow hides them and notes that system colors are in use.
276 usingSystemColors = s.dynamicColor && dynamicColorAvailable,
277 onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
278 )
279 }
281 Spacer(Modifier.size(8.dp))
282 // Dynamic color (Material You) only works on Android 12+, so hide the toggle
283 // on older devices instead of offering a control that silently does nothing.
284 if (dynamicColorAvailable) {
285 val amoledActive = s.themeMode == ThemeMode.AMOLED
286 Row(
287 modifier = Modifier
288 .fillMaxWidth()
289 .defaultMinSize(minHeight = 48.dp)
290 .toggleable(
291 value = s.dynamicColor,
292 enabled = !amoledActive,
293 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
294 role = Role.Switch,
295 )
296 .padding(vertical = 8.dp),
297 verticalAlignment = Alignment.CenterVertically,
298 ) {
299 Column(modifier = Modifier.weight(1f)) {
300 Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
301 Text(
302 stringResource(R.string.dynamic_color_desc),
303 style = MaterialTheme.typography.bodySmall,
304 color = MaterialTheme.colorScheme.onSurfaceVariant,
305 )
306 if (amoledActive) {
307 Text(
308 stringResource(R.string.dynamic_color_disabled_amoled),
309 style = MaterialTheme.typography.bodySmall,
310 color = MaterialTheme.colorScheme.onSurfaceVariant,
311 )
312 }
313 }
314 Switch(
315 checked = s.dynamicColor,
316 onCheckedChange = null,
317 enabled = !amoledActive,
318 )
319 }
320 }
322 // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
323 Spacer(Modifier.size(4.dp))
324 Row(
325 modifier = Modifier
326 .fillMaxWidth()
327 .defaultMinSize(minHeight = 48.dp)
328 .toggleable(
329 value = s.sendOnEnter,
330 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(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.send_on_enter), style = MaterialTheme.typography.bodyLarge)
338 Text(
339 stringResource(R.string.send_on_enter_desc),
340 style = MaterialTheme.typography.bodySmall,
341 color = MaterialTheme.colorScheme.onSurfaceVariant,
342 )
343 }
344 Switch(
345 checked = s.sendOnEnter,
346 onCheckedChange = null,
347 )
348 }
350 // App lock: gate on device capability. Always allow turning it *off* (in case
351 // the enrolled biometric was later removed), but only allow turning it on when
352 // a biometric or device credential is actually available.
353 val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
354 val appLockToggleable = appLockAvailable || s.appLock
355 Spacer(Modifier.size(4.dp))
356 Row(
357 modifier = Modifier
358 .fillMaxWidth()
359 .defaultMinSize(minHeight = 48.dp)
360 .toggleable(
361 value = s.appLock,
362 enabled = appLockToggleable,
363 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
364 role = Role.Switch,
365 )
366 .padding(vertical = 8.dp),
367 verticalAlignment = Alignment.CenterVertically,
368 ) {
369 Column(modifier = Modifier.weight(1f)) {
370 Text(stringResource(R.string.app_lock), style = MaterialTheme.typography.bodyLarge)
371 Text(
372 stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
373 style = MaterialTheme.typography.bodySmall,
374 color = MaterialTheme.colorScheme.onSurfaceVariant,
375 )
376 }
377 Switch(
378 checked = s.appLock,
379 onCheckedChange = null,
380 enabled = appLockToggleable,
381 )
382 }
383 if (s.appLock) {
384 AppLockReLockSelector(
385 seconds = appLockReLockSeconds,
386 onSelect = { sec ->
387 scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
388 },
389 )
390 }
391 } else {
392 Box(
393 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
394 contentAlignment = Alignment.Center,
395 ) {
396 CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
397 }
398 }
400 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
402 Text(
403 stringResource(R.string.text_display),
404 style = MaterialTheme.typography.titleSmall,
405 color = MaterialTheme.colorScheme.primary,
406 modifier = Modifier.semantics { heading() },
407 )
408 ChatTextSizeControl(
409 scale = chatTextScale,
410 onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
411 )
412 Spacer(Modifier.size(4.dp))
413 Row(
414 modifier = Modifier
415 .fillMaxWidth()
416 .defaultMinSize(minHeight = 48.dp)
417 .toggleable(
418 value = codeWrap,
419 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
420 role = Role.Switch,
421 )
422 .padding(vertical = 8.dp),
423 verticalAlignment = Alignment.CenterVertically,
424 ) {
425 Column(modifier = Modifier.weight(1f)) {
426 Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
427 Text(
428 stringResource(R.string.wrap_code_blocks_desc),
429 style = MaterialTheme.typography.bodySmall,
430 color = MaterialTheme.colorScheme.onSurfaceVariant,
431 )
432 }
433 Switch(checked = codeWrap, onCheckedChange = null)
434 }
436 // Compact message spacing: a power-user toggle to fit more conversation on screen.
437 Spacer(Modifier.size(4.dp))
438 Row(
439 modifier = Modifier
440 .fillMaxWidth()
441 .defaultMinSize(minHeight = 48.dp)
442 .toggleable(
443 value = compactSpacing,
444 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
445 role = Role.Switch,
446 )
447 .padding(vertical = 8.dp),
448 verticalAlignment = Alignment.CenterVertically,
449 ) {
450 Column(modifier = Modifier.weight(1f)) {
451 Text(stringResource(R.string.compact_message_spacing), style = MaterialTheme.typography.bodyLarge)
452 Text(
453 stringResource(R.string.compact_message_spacing_desc),
454 style = MaterialTheme.typography.bodySmall,
455 color = MaterialTheme.colorScheme.onSurfaceVariant,
456 )
457 }
458 Switch(checked = compactSpacing, onCheckedChange = null)
459 }
461 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
463 Text(
464 stringResource(R.string.connection),
465 style = MaterialTheme.typography.titleSmall,
466 color = MaterialTheme.colorScheme.primary,
467 modifier = Modifier.semantics { heading() },
468 )
469 if (activeProfile != null) {
470 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
471 Text(
472 activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
473 style = MaterialTheme.typography.bodySmall,
474 color = MaterialTheme.colorScheme.onSurfaceVariant,
475 )
476 // Surface the SSE stream state so a user who opens Settings during a
477 // reconnect saw it (the ConnectionBanner on other screens handles this
478 // elsewhere). Only show non-Connected states to avoid clutter. On a hard
479 // failure offer an inline Retry — without it a user who opened Settings
480 // during a failure is stuck on an error message with no recovery path
481 // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
482 // host it). Retry forces an SSE reconnect, which re-seeds from REST.
483 val stateText = when (connectionState) {
484 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
485 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
486 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
487 EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
488 EventStreamClient.ConnectionState.Connected -> null
489 }
490 stateText?.let {
491 Row(
492 modifier = Modifier.padding(top = 4.dp),
493 verticalAlignment = Alignment.CenterVertically,
494 ) {
495 val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
496 connectionState == EventStreamClient.ConnectionState.AuthFailed
497 Text(
498 it,
499 style = MaterialTheme.typography.bodySmall,
500 color = if (isHardFailure)
501 MaterialTheme.colorScheme.error
502 else MaterialTheme.colorScheme.onSurfaceVariant,
503 )
504 if (isHardFailure) {
505 TextButton(
506 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
507 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
508 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
509 ) { Text(stringResource(R.string.retry_now)) }
510 }
511 }
512 }
513 } else {
514 Text(
515 stringResource(R.string.not_connected),
516 modifier = Modifier.padding(top = 8.dp),
517 color = MaterialTheme.colorScheme.onSurfaceVariant,
518 )
519 }
520 Row(
521 modifier = Modifier
522 .fillMaxWidth()
523 .defaultMinSize(minHeight = 48.dp)
524 .padding(top = 8.dp)
525 .clickable(role = Role.Button) { onManageServers() }
526 .padding(vertical = 8.dp),
527 verticalAlignment = Alignment.CenterVertically,
528 ) {
529 Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
530 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
531 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
532 }
533 NavRow(
534 icon = Icons.Filled.QueryStats,
535 label = stringResource(R.string.usage_title),
536 enabled = activeProfile != null,
537 onClick = onOpenUsage,
538 )
539 NavRow(
540 icon = Icons.Filled.Hub,
541 label = stringResource(R.string.mcp_servers),
542 enabled = activeProfile != null,
543 onClick = onOpenMcp,
544 )
545 NavRow(
546 icon = Icons.Filled.Notifications,
547 label = stringResource(R.string.notification_settings),
548 enabled = true,
549 onClick = {
550 scope.launch {
551 val opened = runCatchingCancellable {
552 val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
553 .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
554 context.startActivity(intent)
555 }.isSuccess
556 if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
557 }
558 },
559 )
561 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
563 Text(
564 stringResource(R.string.backup_restore),
565 style = MaterialTheme.typography.titleSmall,
566 color = MaterialTheme.colorScheme.primary,
567 modifier = Modifier.semantics { heading() },
568 )
569 Text(
570 stringResource(R.string.backup_desc),
571 style = MaterialTheme.typography.bodySmall,
572 color = MaterialTheme.colorScheme.onSurfaceVariant,
573 modifier = Modifier.padding(top = 4.dp),
574 )
575 Row(
576 modifier = Modifier
577 .fillMaxWidth()
578 .defaultMinSize(minHeight = 48.dp)
579 .toggleable(
580 value = includePasswords,
581 onValueChange = { includePasswords = it },
582 role = Role.Switch,
583 )
584 .padding(vertical = 8.dp),
585 verticalAlignment = Alignment.CenterVertically,
586 ) {
587 Column(modifier = Modifier.weight(1f)) {
588 Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
589 Text(
590 stringResource(R.string.backup_include_passwords_desc),
591 style = MaterialTheme.typography.bodySmall,
592 color = MaterialTheme.colorScheme.onSurfaceVariant,
593 )
594 }
595 Switch(checked = includePasswords, onCheckedChange = null)
596 }
597 NavRow(
598 icon = Icons.Filled.Upload,
599 label = stringResource(R.string.export_backup),
600 enabled = !backupBusy,
601 // Warn before writing plaintext credentials out of the app; confirm then launches
602 // the file picker. Without passwords, export straight away.
603 onClick = {
604 if (includePasswords) showExportPasswordWarning = true
605 else runCatching { exportLauncher.launch(backupFilename()) }
606 },
607 )
608 NavRow(
609 icon = Icons.Filled.Download,
610 label = stringResource(R.string.import_backup),
611 enabled = !backupBusy,
612 onClick = {
613 runCatching {
614 importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
615 }
616 },
617 )
618 // Surface an inline progress indicator while a backup write/read is in flight so
619 // the user gets immediate feedback that the (potentially slow on a big file)
620 // operation is running, and isn't tempted to tap again.
621 if (backupBusy) {
622 LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
623 }
625 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
627 Text(
628 stringResource(R.string.about),
629 style = MaterialTheme.typography.titleSmall,
630 color = MaterialTheme.colorScheme.primary,
631 modifier = Modifier.semantics { heading() },
632 )
633 Text(
634 stringResource(R.string.about_version, versionName),
635 style = MaterialTheme.typography.bodyMedium,
636 modifier = Modifier.padding(top = 8.dp),
637 )
638 Text(
639 stringResource(R.string.about_desc),
640 style = MaterialTheme.typography.bodySmall,
641 color = MaterialTheme.colorScheme.onSurfaceVariant,
642 modifier = Modifier.padding(top = 4.dp),
643 )
645 Spacer(Modifier.size(16.dp))
646 Row(
647 modifier = Modifier
648 .fillMaxWidth()
649 .defaultMinSize(minHeight = 48.dp)
650 .clickable(role = Role.Button) { onOpenDiagnostics() }
651 .padding(vertical = 8.dp),
652 verticalAlignment = Alignment.CenterVertically,
653 ) {
654 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
655 Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
656 // Badge with the crash count so the user can tell at a glance whether
657 // there's something worth investigating.
658 if (crashCount > 0) {
659 Badge(count = crashCount)
660 Spacer(Modifier.size(8.dp))
661 }
662 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
663 }
664 // Discoverability links from the About section: source code and issue tracker.
665 // Opens in the browser via ACTION_VIEW; a device without a browser (e.g. a de-Googled
666 // or work-profile split) silently no-ops otherwise, so surface a snackbar on failure.
667 NavRow(
668 icon = Icons.Filled.Code,
669 label = stringResource(R.string.source_code),
670 enabled = true,
671 onClick = {
672 runCatching {
673 context.startActivity(
674 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
675 )
676 }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
677 },
678 )
679 NavRow(
680 icon = Icons.Filled.Feedback,
681 label = stringResource(R.string.report_issue),
682 enabled = true,
683 onClick = {
684 runCatching {
685 context.startActivity(
686 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
687 )
688 }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
689 },
690 )
691 }
692 }
694 // Destructive-import confirmation: applies the staged file only on Replace.
695 pendingImportUri?.let { uri ->
696 AlertDialog(
697 onDismissRequest = { pendingImportUri = null },
698 title = { Text(stringResource(R.string.import_confirm_title)) },
699 text = { Text(stringResource(R.string.import_confirm_text)) },
700 confirmButton = {
701 TextButton(onClick = {
702 pendingImportUri = null
703 scope.launch {
704 backupBusy = true
705 try {
706 val ok = runCatchingCancellable {
707 val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
708 ?: error("no input stream")
709 container.backupManager.import(text)
710 }.isSuccess
711 if (ok) {
712 val result = snackbar.showSnackbar(
713 message = importedMsg,
714 actionLabel = restartLabel,
715 )
716 if (result == SnackbarResult.ActionPerformed) {
717 // Shut down the container (cancels the app scope, closes the
718 // active connection, flushes stores) before the hard kill so
719 // in-flight work is cleaned up rather than abruptly severed.
720 // The JVM shutdown hook in OpencodeApp would catch this, but
721 // calling shutdown explicitly is cleaner and avoids relying on
722 // the hook's ordering under a kill.
723 runCatchingCancellable { container.shutdown() }
724 android.os.Process.killProcess(android.os.Process.myPid())
725 }
726 } else {
727 snackbar.showSnackbar(importFailedMsg)
728 }
729 } finally {
730 backupBusy = false
731 }
732 }
733 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
734 },
735 dismissButton = {
736 TextButton(onClick = { pendingImportUri = null }) { Text(stringResource(R.string.cancel)) }
737 },
738 )
739 }
741 // Plaintext-password export warning: only launches the file picker on confirm.
742 if (showExportPasswordWarning) {
743 AlertDialog(
744 onDismissRequest = { showExportPasswordWarning = false },
745 title = { Text(stringResource(R.string.export_passwords_title)) },
746 text = { Text(stringResource(R.string.export_passwords_warning)) },
747 confirmButton = {
748 TextButton(onClick = {
749 showExportPasswordWarning = false
750 runCatching { exportLauncher.launch(backupFilename()) }
751 }) { Text(stringResource(R.string.export_backup), color = MaterialTheme.colorScheme.error) }
752 },
753 dismissButton = {
754 TextButton(onClick = { showExportPasswordWarning = false }) { Text(stringResource(R.string.cancel)) }
755 },
756 )
757 }
760/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
761@Composable
762private fun NavRow(
763 icon: androidx.compose.ui.graphics.vector.ImageVector,
764 label: String,
765 enabled: Boolean,
766 onClick: () -> Unit,
767) {
768 Row(
769 modifier = Modifier
770 .fillMaxWidth()
771 .defaultMinSize(minHeight = 48.dp)
772 .clickable(enabled = enabled, role = Role.Button) { onClick() }
773 .padding(vertical = 8.dp),
774 verticalAlignment = Alignment.CenterVertically,
775 ) {
776 Icon(
777 icon,
778 contentDescription = null,
779 tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
780 )
781 Text(
782 label,
783 modifier = Modifier.weight(1f).padding(start = 8.dp),
784 color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
785 )
786 Icon(
787 Icons.AutoMirrored.Filled.KeyboardArrowRight,
788 contentDescription = null,
789 tint = MaterialTheme.colorScheme.onSurfaceVariant,
790 )
791 }
794/** Chat text-size slider with a live sample line, a percentage readout, and a Reset.
795 * Drives [SettingsStore.chatTextScale]; persists only when the drag settles so a DataStore
796 * write doesn't fire on every pixel of movement. */
797@Composable
798private fun ChatTextSizeControl(scale: Float, onScaleChange: (Float) -> Unit) {
799 // Local slider state so dragging stays smooth; re-seed when the persisted value changes
800 // (e.g. Reset here, or an external write) so the control stays in sync.
801 var sliderValue by remember(scale) { mutableFloatStateOf(scale) }
802 val body = MaterialTheme.typography.bodyLarge
803 Text(
804 stringResource(R.string.text_size_sample),
805 style = body.copy(fontSize = body.fontSize * sliderValue, lineHeight = body.lineHeight * sliderValue),
806 modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
807 )
808 Text(
809 stringResource(R.string.chat_text_size_desc),
810 style = MaterialTheme.typography.bodySmall,
811 color = MaterialTheme.colorScheme.onSurfaceVariant,
812 )
813 Row(verticalAlignment = Alignment.CenterVertically) {
814 Slider(
815 value = sliderValue,
816 onValueChange = { sliderValue = it },
817 onValueChangeFinished = { onScaleChange(sliderValue) },
818 valueRange = SettingsStore.MIN_CHAT_TEXT_SCALE..SettingsStore.MAX_CHAT_TEXT_SCALE,
819 modifier = Modifier.weight(1f),
820 )
821 Text(
822 stringResource(R.string.text_scale_value, (sliderValue * 100).roundToInt()),
823 style = MaterialTheme.typography.labelLarge,
824 textAlign = TextAlign.End,
825 modifier = Modifier.widthIn(min = 44.dp).padding(start = 8.dp),
826 )
827 }
828 TextButton(
829 onClick = {
830 sliderValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE
831 onScaleChange(SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
832 },
833 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
834 ) { Text(stringResource(R.string.reset)) }
835 // The app text scale multiplies on top of the OS font scale, and the effective size is
836 // clamped so the product can't exceed the combined cap (see OpencodeApp). When the user's
837 // system font is already large, the value they pick here would be silently reduced — so
838 // surface that the effective size is capped rather than letting the slider appear broken.
839 val systemFontScale = LocalDensity.current.fontScale
840 if (systemFontScale > 0f) {
841 val effective = (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
842 if (effective < sliderValue) {
843 Text(
844 stringResource(R.string.text_size_capped, (effective * 100).roundToInt()),
845 style = MaterialTheme.typography.bodySmall,
846 color = MaterialTheme.colorScheme.onSurfaceVariant,
847 modifier = Modifier.padding(top = 2.dp),
848 )
849 }
850 }
853@Composable
854private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Boolean, onSelect: () -> Unit) {
855 // Preview swatches so the user can see what each mode looks like before selecting,
856 // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
857 // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
858 val swatches: List<Color> = when (mode) {
859 ThemeMode.SYSTEM -> {
860 val dark = isSystemInDarkThemeStatic()
861 if (dark) DarkSwatches else LightSwatches
862 }
863 ThemeMode.LIGHT -> LightSwatches
864 ThemeMode.DARK -> DarkSwatches
865 ThemeMode.AMOLED -> AmoledSwatches
866 }
867 Row(
868 modifier = Modifier
869 .fillMaxWidth()
870 .defaultMinSize(minHeight = 48.dp)
871 .selectable(selected = selected, onClick = onSelect)
872 .padding(vertical = 8.dp),
873 horizontalArrangement = Arrangement.spacedBy(8.dp),
874 verticalAlignment = Alignment.CenterVertically,
875 ) {
876 RadioButton(selected = selected, onClick = null)
877 Text(
878 when (mode) {
879 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
880 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
881 ThemeMode.DARK -> stringResource(R.string.theme_dark)
882 ThemeMode.AMOLED -> stringResource(R.string.theme_amoled)
883 },
884 style = MaterialTheme.typography.bodyLarge,
885 modifier = Modifier.weight(1f),
886 )
887 if (usingSystemColors) {
888 // Dynamic color (Material You) overrides the static palettes, so the dots would
889 // misrepresent the actual colors — note that system colors are in use instead.
890 Text(
891 stringResource(R.string.theme_using_system_colors),
892 style = MaterialTheme.typography.labelSmall,
893 color = MaterialTheme.colorScheme.onSurfaceVariant,
894 textAlign = TextAlign.End,
895 )
896 } else {
897 // Three small color dots previewing the palette for this mode.
898 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
899 swatches.forEach { color ->
900 Box(
901 modifier = Modifier
902 .size(14.dp)
903 .clip(CircleShape)
904 .background(color),
905 )
906 }
907 }
908 }
909 }
912// Palette preview dots for the theme rows. Primary/secondary/tertiary/background convey
913// the character of each scheme at a glance without rendering a full sample surface.
914private val DarkSwatches: List<Color> = DarkPaletteSwatches
915private val LightSwatches: List<Color> = LightPaletteSwatches
916private val AmoledSwatches: List<Color> = AmoledPaletteSwatches
918// Composable-side read of the system dark setting for the "System" theme row preview.
919// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
920// helper; hoist it here so the ThemeRow stays a plain @Composable with no preview-time
921// recomposition surprises.
922@Composable
923private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
925/** Small wrapper so the three appearance settings load atomically and the UI can gate
926 * on a single null check instead of flashing hardcoded defaults. */
927private data class SettingsValues(
928 val themeMode: ThemeMode,
929 val dynamicColor: Boolean,
930 val sendOnEnter: Boolean,
931 val appLock: Boolean,
934/** Small circular count badge used to indicate pending crash reports. */
935@Composable
936private fun Badge(count: Int) {
937 val overflow = stringResource(R.string.crash_count_overflow)
938 Box(
939 modifier = Modifier
940 .size(20.dp)
941 .clip(CircleShape)
942 .background(MaterialTheme.colorScheme.error),
943 contentAlignment = Alignment.Center,
944 ) {
945 Text(
946 if (count > 99) overflow else count.toString(),
947 style = MaterialTheme.typography.labelSmall,
948 color = MaterialTheme.colorScheme.onError,
949 textAlign = TextAlign.Center,
950 )
951 }
954/** Dropdown selecting how long after backgrounding the app re-locks. Only shown when app lock
955 * is on. The grace period avoids a re-prompt on every quick app-switch (the most common reason
956 * users disable biometric locks), while still re-locking immediately by default. */
957@OptIn(ExperimentalMaterial3Api::class)
958@Composable
959private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
960 var expanded by remember { mutableStateOf(false) }
961 ExposedDropdownMenuBox(
962 expanded = expanded,
963 onExpandedChange = { expanded = it },
964 modifier = Modifier
965 .fillMaxWidth()
966 .padding(start = 56.dp, top = 4.dp),
967 ) {
968 OutlinedTextField(
969 value = relockLabel(seconds),
970 onValueChange = {},
971 readOnly = true,
972 singleLine = true,
973 label = { Text(stringResource(R.string.app_lock_relock)) },
974 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
975 modifier = Modifier
976 .menuAnchor(MenuAnchorType.PrimaryNotEditable, true)
977 .fillMaxWidth(),
978 )
979 ExposedDropdownMenu(
980 expanded = expanded,
981 onDismissRequest = { expanded = false },
982 ) {
983 SettingsStore.APP_LOCK_RELOCK_OPTIONS_SECONDS.forEach { option ->
984 DropdownMenuItem(
985 text = { Text(relockLabel(option)) },
986 onClick = { expanded = false; onSelect(option) },
987 )
988 }
989 }
990 }
993@Composable
994private fun relockLabel(seconds: Int): String = when {
995 seconds <= 0 -> stringResource(R.string.app_lock_relock_immediately)
996 seconds < 60 -> pluralStringResource(R.plurals.app_lock_relock_seconds, seconds, seconds)
997 seconds < 3600 -> {
998 val mins = seconds / 60
999 pluralStringResource(R.plurals.app_lock_relock_minutes, mins, mins)
1001 else -> {
1002 val hours = seconds / 3600
1003 pluralStringResource(R.plurals.app_lock_relock_hours, hours, hours)