git.iko.soy/opencode-android
on 06fd41b help sign in
942 lines 45 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.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)
106@Composable
107fun SettingsScreen(
108 container: AppContainer,
109 onBack: () -> Unit,
110 onManageServers: () -> Unit,
111 onOpenDiagnostics: () -> Unit,
112 onOpenUsage: () -> Unit = {},
113 onOpenMcp: () -> Unit = {},
114) {
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) {
124 combine(
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? }
130 }
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) {
139 biometricCheckTick++
140 onPauseOrDispose { }
141 }
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) }
160 }
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 {
171 runCatching {
172 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
173 context.packageManager.getPackageInfo(
174 context.packageName,
175 PackageManager.PackageInfoFlags.of(0),
176 ).versionName
177 } else {
178 @Suppress("DEPRECATION")
179 context.packageManager.getPackageInfo(context.packageName, 0).versionName
180 }
181 }.getOrNull() ?: unknownVersion
182 }
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"),
201 ) { uri ->
202 if (uri == null) return@rememberLauncherForActivityResult
203 scope.launch {
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")
208 }.isSuccess
209 snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
210 }
211 }
212 val importLauncher = rememberLauncherForActivityResult(
213 ActivityResultContracts.OpenDocument(),
214 ) { uri ->
215 if (uri == null) return@rememberLauncherForActivityResult
216 // Stage the file; the actual destructive import waits on the confirm dialog.
217 pendingImportUri = uri
218 }
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"
225 }
227 Scaffold(
228 topBar = {
229 TopAppBar(
230 title = { Text(stringResource(R.string.settings)) },
231 navigationIcon = {
232 IconButton(onClick = onBack) {
233 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
234 }
235 },
236 )
237 },
238 snackbarHost = { SnackbarHost(snackbar) },
239 ) { padding ->
240 Column(
241 modifier = Modifier
242 .padding(padding)
243 .fillMaxWidth()
244 .wrapContentWidth(Alignment.CenterHorizontally)
245 .widthIn(max = 600.dp)
246 .verticalScroll(rememberScrollState())
247 .padding(16.dp),
248 ) {
249 val s = settings
250 Text(
251 stringResource(R.string.appearance),
252 style = MaterialTheme.typography.titleSmall,
253 color = MaterialTheme.colorScheme.primary,
254 modifier = Modifier.semantics { heading() },
255 )
256 if (s != null) {
257 ThemeMode.entries.forEach { mode ->
258 ThemeRow(
259 mode = 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) } } },
265 )
266 }
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
273 Row(
274 modifier = Modifier
275 .fillMaxWidth()
276 .toggleable(
277 value = s.dynamicColor,
278 enabled = !amoledActive,
279 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
280 role = Role.Switch,
281 )
282 .padding(vertical = 8.dp),
283 verticalAlignment = Alignment.CenterVertically,
284 ) {
285 Column(modifier = Modifier.weight(1f)) {
286 Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
287 Text(
288 stringResource(R.string.dynamic_color_desc),
289 style = MaterialTheme.typography.bodySmall,
290 color = MaterialTheme.colorScheme.onSurfaceVariant,
291 )
292 if (amoledActive) {
293 Text(
294 stringResource(R.string.dynamic_color_disabled_amoled),
295 style = MaterialTheme.typography.bodySmall,
296 color = MaterialTheme.colorScheme.onSurfaceVariant,
297 )
298 }
299 }
300 Switch(
301 checked = s.dynamicColor,
302 onCheckedChange = null,
303 enabled = !amoledActive,
304 )
305 }
306 }
308 // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
309 Spacer(Modifier.size(4.dp))
310 Row(
311 modifier = Modifier
312 .fillMaxWidth()
313 .toggleable(
314 value = s.sendOnEnter,
315 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
316 role = Role.Switch,
317 )
318 .padding(vertical = 8.dp),
319 verticalAlignment = Alignment.CenterVertically,
320 ) {
321 Column(modifier = Modifier.weight(1f)) {
322 Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
323 Text(
324 stringResource(R.string.send_on_enter_desc),
325 style = MaterialTheme.typography.bodySmall,
326 color = MaterialTheme.colorScheme.onSurfaceVariant,
327 )
328 }
329 Switch(
330 checked = s.sendOnEnter,
331 onCheckedChange = null,
332 )
333 }
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))
341 Row(
342 modifier = Modifier
343 .fillMaxWidth()
344 .toggleable(
345 value = s.appLock,
346 enabled = appLockToggleable,
347 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
348 role = Role.Switch,
349 )
350 .padding(vertical = 8.dp),
351 verticalAlignment = Alignment.CenterVertically,
352 ) {
353 Column(modifier = Modifier.weight(1f)) {
354 Text(stringResource(R.string.app_lock), style = MaterialTheme.typography.bodyLarge)
355 Text(
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,
359 )
360 }
361 Switch(
362 checked = s.appLock,
363 onCheckedChange = null,
364 enabled = appLockToggleable,
365 )
366 }
367 if (s.appLock) {
368 AppLockReLockSelector(
369 seconds = appLockReLockSeconds,
370 onSelect = { sec ->
371 scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
372 },
373 )
374 }
375 } else {
376 Box(
377 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
378 contentAlignment = Alignment.Center,
379 ) {
380 CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
381 }
382 }
384 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
386 Text(
387 stringResource(R.string.text_display),
388 style = MaterialTheme.typography.titleSmall,
389 color = MaterialTheme.colorScheme.primary,
390 modifier = Modifier.semantics { heading() },
391 )
392 ChatTextSizeControl(
393 scale = chatTextScale,
394 onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
395 )
396 Spacer(Modifier.size(4.dp))
397 Row(
398 modifier = Modifier
399 .fillMaxWidth()
400 .toggleable(
401 value = codeWrap,
402 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
403 role = Role.Switch,
404 )
405 .padding(vertical = 8.dp),
406 verticalAlignment = Alignment.CenterVertically,
407 ) {
408 Column(modifier = Modifier.weight(1f)) {
409 Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
410 Text(
411 stringResource(R.string.wrap_code_blocks_desc),
412 style = MaterialTheme.typography.bodySmall,
413 color = MaterialTheme.colorScheme.onSurfaceVariant,
414 )
415 }
416 Switch(checked = codeWrap, onCheckedChange = null)
417 }
419 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
421 Text(
422 stringResource(R.string.connection),
423 style = MaterialTheme.typography.titleSmall,
424 color = MaterialTheme.colorScheme.primary,
425 modifier = Modifier.semantics { heading() },
426 )
427 if (activeProfile != null) {
428 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
429 Text(
430 activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
431 style = MaterialTheme.typography.bodySmall,
432 color = MaterialTheme.colorScheme.onSurfaceVariant,
433 )
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
447 }
448 stateText?.let {
449 Row(
450 modifier = Modifier.padding(top = 4.dp),
451 verticalAlignment = Alignment.CenterVertically,
452 ) {
453 val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
454 connectionState == EventStreamClient.ConnectionState.AuthFailed
455 Text(
456 it,
457 style = MaterialTheme.typography.bodySmall,
458 color = if (isHardFailure)
459 MaterialTheme.colorScheme.error
460 else MaterialTheme.colorScheme.onSurfaceVariant,
461 )
462 if (isHardFailure) {
463 TextButton(
464 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
465 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
466 ) { Text(stringResource(R.string.retry_now)) }
467 }
468 }
469 }
470 } else {
471 Text(
472 stringResource(R.string.not_connected),
473 modifier = Modifier.padding(top = 8.dp),
474 color = MaterialTheme.colorScheme.onSurfaceVariant,
475 )
476 }
477 Row(
478 modifier = Modifier
479 .fillMaxWidth()
480 .defaultMinSize(minHeight = 48.dp)
481 .padding(top = 8.dp)
482 .clickable(role = Role.Button) { onManageServers() }
483 .padding(vertical = 8.dp),
484 verticalAlignment = Alignment.CenterVertically,
485 ) {
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)
489 }
490 NavRow(
491 icon = Icons.Filled.QueryStats,
492 label = stringResource(R.string.usage_title),
493 enabled = activeProfile != null,
494 onClick = onOpenUsage,
495 )
496 NavRow(
497 icon = Icons.Filled.Hub,
498 label = stringResource(R.string.mcp_servers),
499 enabled = activeProfile != null,
500 onClick = onOpenMcp,
501 )
502 NavRow(
503 icon = Icons.Filled.Notifications,
504 label = stringResource(R.string.notification_settings),
505 enabled = true,
506 onClick = {
507 scope.launch {
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)
512 }.isSuccess
513 if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
514 }
515 },
516 )
518 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
520 Text(
521 stringResource(R.string.backup_restore),
522 style = MaterialTheme.typography.titleSmall,
523 color = MaterialTheme.colorScheme.primary,
524 modifier = Modifier.semantics { heading() },
525 )
526 Text(
527 stringResource(R.string.backup_desc),
528 style = MaterialTheme.typography.bodySmall,
529 color = MaterialTheme.colorScheme.onSurfaceVariant,
530 modifier = Modifier.padding(top = 4.dp),
531 )
532 Row(
533 modifier = Modifier
534 .fillMaxWidth()
535 .toggleable(
536 value = includePasswords,
537 onValueChange = { includePasswords = it },
538 role = Role.Switch,
539 )
540 .padding(vertical = 8.dp),
541 verticalAlignment = Alignment.CenterVertically,
542 ) {
543 Column(modifier = Modifier.weight(1f)) {
544 Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
545 Text(
546 stringResource(R.string.backup_include_passwords_desc),
547 style = MaterialTheme.typography.bodySmall,
548 color = MaterialTheme.colorScheme.onSurfaceVariant,
549 )
550 }
551 Switch(checked = includePasswords, onCheckedChange = null)
552 }
553 NavRow(
554 icon = Icons.Filled.Upload,
555 label = stringResource(R.string.export_backup),
556 enabled = true,
557 // Warn before writing plaintext credentials out of the app; confirm then launches
558 // the file picker. Without passwords, export straight away.
559 onClick = {
560 if (includePasswords) showExportPasswordWarning = true
561 else runCatching { exportLauncher.launch(backupFilename()) }
562 },
563 )
564 NavRow(
565 icon = Icons.Filled.Download,
566 label = stringResource(R.string.import_backup),
567 enabled = true,
568 onClick = {
569 runCatching {
570 importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
571 }
572 },
573 )
575 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
577 Text(
578 stringResource(R.string.about),
579 style = MaterialTheme.typography.titleSmall,
580 color = MaterialTheme.colorScheme.primary,
581 modifier = Modifier.semantics { heading() },
582 )
583 Text(
584 stringResource(R.string.about_version, versionName),
585 style = MaterialTheme.typography.bodyMedium,
586 modifier = Modifier.padding(top = 8.dp),
587 )
588 Text(
589 stringResource(R.string.about_desc),
590 style = MaterialTheme.typography.bodySmall,
591 color = MaterialTheme.colorScheme.onSurfaceVariant,
592 modifier = Modifier.padding(top = 4.dp),
593 )
595 Spacer(Modifier.size(16.dp))
596 Row(
597 modifier = Modifier
598 .fillMaxWidth()
599 .defaultMinSize(minHeight = 48.dp)
600 .clickable(role = Role.Button) { onOpenDiagnostics() }
601 .padding(vertical = 8.dp),
602 verticalAlignment = Alignment.CenterVertically,
603 ) {
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))
611 }
612 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
613 }
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.
617 NavRow(
618 icon = Icons.Filled.Code,
619 label = stringResource(R.string.source_code),
620 enabled = true,
621 onClick = {
622 runCatching {
623 context.startActivity(
624 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
625 )
626 }
627 },
628 )
629 NavRow(
630 icon = Icons.Filled.Feedback,
631 label = stringResource(R.string.report_issue),
632 enabled = true,
633 onClick = {
634 runCatching {
635 context.startActivity(
636 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
637 )
638 }
639 },
640 )
641 }
642 }
644 // Destructive-import confirmation: applies the staged file only on Replace.
645 pendingImportUri?.let { uri ->
646 AlertDialog(
647 onDismissRequest = { pendingImportUri = null },
648 title = { Text(stringResource(R.string.import_confirm_title)) },
649 text = { Text(stringResource(R.string.import_confirm_text)) },
650 confirmButton = {
651 TextButton(onClick = {
652 pendingImportUri = null
653 scope.launch {
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)
658 }.isSuccess
659 if (ok) {
660 val result = snackbar.showSnackbar(
661 message = importedMsg,
662 actionLabel = restartLabel,
663 )
664 if (result == SnackbarResult.ActionPerformed) {
665 android.os.Process.killProcess(android.os.Process.myPid())
666 }
667 } else {
668 snackbar.showSnackbar(importFailedMsg)
669 }
670 }
671 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
672 },
673 dismissButton = {
674 TextButton(onClick = { pendingImportUri = null }) { Text(stringResource(R.string.cancel)) }
675 },
676 )
677 }
679 // Plaintext-password export warning: only launches the file picker on confirm.
680 if (showExportPasswordWarning) {
681 AlertDialog(
682 onDismissRequest = { showExportPasswordWarning = false },
683 title = { Text(stringResource(R.string.export_passwords_title)) },
684 text = { Text(stringResource(R.string.export_passwords_warning)) },
685 confirmButton = {
686 TextButton(onClick = {
687 showExportPasswordWarning = false
688 runCatching { exportLauncher.launch(backupFilename()) }
689 }) { Text(stringResource(R.string.export_backup), color = MaterialTheme.colorScheme.error) }
690 },
691 dismissButton = {
692 TextButton(onClick = { showExportPasswordWarning = false }) { Text(stringResource(R.string.cancel)) }
693 },
694 )
695 }
698/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
699@Composable
700private fun NavRow(
701 icon: androidx.compose.ui.graphics.vector.ImageVector,
702 label: String,
703 enabled: Boolean,
704 onClick: () -> Unit,
705) {
706 Row(
707 modifier = Modifier
708 .fillMaxWidth()
709 .defaultMinSize(minHeight = 48.dp)
710 .clickable(enabled = enabled, role = Role.Button) { onClick() }
711 .padding(vertical = 8.dp),
712 verticalAlignment = Alignment.CenterVertically,
713 ) {
714 Icon(
715 icon,
716 contentDescription = null,
717 tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
718 )
719 Text(
720 label,
721 modifier = Modifier.weight(1f).padding(start = 8.dp),
722 color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
723 )
724 Icon(
725 Icons.AutoMirrored.Filled.KeyboardArrowRight,
726 contentDescription = null,
727 tint = MaterialTheme.colorScheme.onSurfaceVariant,
728 )
729 }
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. */
735@Composable
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
741 Text(
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),
745 )
746 Text(
747 stringResource(R.string.chat_text_size_desc),
748 style = MaterialTheme.typography.bodySmall,
749 color = MaterialTheme.colorScheme.onSurfaceVariant,
750 )
751 Row(verticalAlignment = Alignment.CenterVertically) {
752 Slider(
753 value = sliderValue,
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),
758 )
759 Text(
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),
764 )
765 }
766 TextButton(
767 onClick = {
768 sliderValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE
769 onScaleChange(SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
770 },
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) {
781 Text(
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),
786 )
787 }
788 }
791@Composable
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
800 }
801 ThemeMode.LIGHT -> LightSwatches
802 ThemeMode.DARK -> DarkSwatches
803 ThemeMode.AMOLED -> AmoledSwatches
804 }
805 Row(
806 modifier = Modifier
807 .fillMaxWidth()
808 .selectable(selected = selected, onClick = onSelect)
809 .padding(vertical = 8.dp),
810 horizontalArrangement = Arrangement.spacedBy(8.dp),
811 verticalAlignment = Alignment.CenterVertically,
812 ) {
813 RadioButton(selected = selected, onClick = null)
814 Text(
815 when (mode) {
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)
820 },
821 style = MaterialTheme.typography.bodyLarge,
822 modifier = Modifier.weight(1f),
823 )
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.
827 Text(
828 stringResource(R.string.theme_using_system_colors),
829 style = MaterialTheme.typography.labelSmall,
830 color = MaterialTheme.colorScheme.onSurfaceVariant,
831 textAlign = TextAlign.End,
832 )
833 } else {
834 // Three small color dots previewing the palette for this mode.
835 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
836 swatches.forEach { color ->
837 Box(
838 modifier = Modifier
839 .size(14.dp)
840 .clip(CircleShape)
841 .background(color),
842 )
843 }
844 }
845 }
846 }
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.
859@Composable
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. */
872@Composable
873private fun Badge(count: Int) {
874 val overflow = stringResource(R.string.crash_count_overflow)
875 Box(
876 modifier = Modifier
877 .size(20.dp)
878 .clip(CircleShape)
879 .background(MaterialTheme.colorScheme.error),
880 contentAlignment = Alignment.Center,
881 ) {
882 Text(
883 if (count > 99) overflow else count.toString(),
884 style = MaterialTheme.typography.labelSmall,
885 color = MaterialTheme.colorScheme.onError,
886 textAlign = TextAlign.Center,
887 )
888 }
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)
895@Composable
896private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
897 var expanded by remember { mutableStateOf(false) }
898 ExposedDropdownMenuBox(
899 expanded = expanded,
900 onExpandedChange = { expanded = it },
901 modifier = Modifier
902 .fillMaxWidth()
903 .padding(start = 56.dp, top = 4.dp),
904 ) {
905 OutlinedTextField(
906 value = relockLabel(seconds),
907 onValueChange = {},
908 readOnly = true,
909 singleLine = true,
910 label = { Text(stringResource(R.string.app_lock_relock)) },
911 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
912 modifier = Modifier
913 .menuAnchor(MenuAnchorType.PrimaryNotEditable, true)
914 .fillMaxWidth(),
915 )
916 ExposedDropdownMenu(
917 expanded = expanded,
918 onDismissRequest = { expanded = false },
919 ) {
920 SettingsStore.APP_LOCK_RELOCK_OPTIONS_SECONDS.forEach { option ->
921 DropdownMenuItem(
922 text = { Text(relockLabel(option)) },
923 onClick = { expanded = false; onSelect(option) },
924 )
925 }
926 }
927 }
930@Composable
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)
934 seconds < 3600 -> {
935 val mins = seconds / 60
936 pluralStringResource(R.plurals.app_lock_relock_minutes, mins, mins)
937 }
938 else -> {
939 val hours = seconds / 3600
940 pluralStringResource(R.plurals.app_lock_relock_hours, hours, hours)
941 }