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.text.KeyboardActions
26import androidx.compose.foundation.text.KeyboardOptions
27import androidx.compose.foundation.verticalScroll
28import androidx.compose.foundation.shape.CircleShape
29import androidx.compose.material.icons.Icons
30import androidx.compose.material.icons.automirrored.filled.ArrowBack
31import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
32import androidx.compose.material.icons.filled.BugReport
33import androidx.compose.material.icons.filled.Clear
34import androidx.compose.material.icons.filled.Code
35import androidx.compose.material.icons.filled.Dns
36import androidx.compose.material.icons.filled.Download
37import androidx.compose.material.icons.filled.Feedback
38import androidx.compose.material.icons.filled.Hub
39import androidx.compose.material.icons.filled.Notifications
40import androidx.compose.material.icons.filled.QueryStats
41import androidx.compose.material.icons.filled.Search
42import androidx.compose.material.icons.filled.Upload
43import androidx.compose.material3.AlertDialog
44import androidx.compose.material3.ExperimentalMaterial3Api
45import androidx.compose.material3.Badge
46import androidx.compose.material3.BadgedBox
47import androidx.compose.material3.CircularProgressIndicator
48import androidx.compose.material3.DropdownMenu
49import androidx.compose.material3.DropdownMenuItem
50import androidx.compose.material3.ExposedDropdownMenuBox
51import androidx.compose.material3.ExposedDropdownMenuDefaults
52import androidx.compose.material3.HorizontalDivider
53import androidx.compose.material3.Icon
54import androidx.compose.material3.IconButton
55import androidx.compose.material3.LinearProgressIndicator
56import androidx.compose.material3.MaterialTheme
57import androidx.compose.material3.MenuAnchorType
58import androidx.compose.material3.OutlinedTextField
59import androidx.compose.material3.RadioButton
60import androidx.compose.material3.Scaffold
61import androidx.compose.material3.SnackbarHost
62import androidx.compose.material3.SnackbarHostState
63import androidx.compose.material3.Slider
64import androidx.compose.material3.SnackbarResult
65import androidx.compose.material3.Switch
66import androidx.compose.material3.Text
67import androidx.compose.material3.TextButton
68import androidx.compose.material3.TopAppBar
69import androidx.compose.material3.TopAppBarDefaults
70import androidx.compose.runtime.Composable
71import androidx.compose.runtime.getValue
72import androidx.compose.runtime.mutableFloatStateOf
73import androidx.compose.runtime.mutableStateOf
74import androidx.compose.runtime.remember
75import androidx.compose.runtime.rememberCoroutineScope
76import androidx.compose.runtime.saveable.rememberSaveable
77import androidx.compose.runtime.setValue
78import androidx.compose.ui.Alignment
79import androidx.compose.ui.Modifier
80import androidx.compose.ui.draw.clip
81import androidx.compose.ui.graphics.Color
82import androidx.compose.ui.input.nestedscroll.nestedScroll
83import androidx.compose.ui.platform.LocalContext
84import androidx.compose.ui.platform.LocalDensity
85import androidx.compose.ui.platform.LocalSoftwareKeyboardController
86import androidx.compose.ui.platform.testTag
87import androidx.compose.ui.res.pluralStringResource
88import androidx.compose.ui.res.stringResource
89import androidx.compose.ui.semantics.Role
90import androidx.compose.ui.semantics.heading
91import androidx.compose.ui.semantics.role
92import androidx.compose.ui.semantics.semantics
93import androidx.compose.ui.text.input.ImeAction
94import androidx.compose.ui.text.style.TextAlign
95import androidx.compose.ui.unit.dp
96import androidx.lifecycle.compose.LocalLifecycleOwner
97import androidx.lifecycle.compose.LifecycleResumeEffect
98import androidx.lifecycle.compose.collectAsStateWithLifecycle
99import kotlinx.coroutines.ExperimentalCoroutinesApi
100import kotlinx.coroutines.flow.combine
101import kotlinx.coroutines.flow.flatMapLatest
102import kotlinx.coroutines.flow.flowOf
103import kotlinx.coroutines.launch
104import soy.iko.opencode.data.network.EventStreamClient
105import soy.iko.opencode.data.repo.CrashLogger
106import soy.iko.opencode.data.repo.SettingsStore
107import soy.iko.opencode.data.repo.SwipeAction
108import soy.iko.opencode.data.repo.ThemeMode
109import soy.iko.opencode.di.AppContainer
110import soy.iko.opencode.R
111import soy.iko.opencode.data.network.NetworkConfig
112import soy.iko.opencode.ui.canAuthenticateForAppLock
113import soy.iko.opencode.ui.components.ConnectionBanner
114import soy.iko.opencode.ui.theme.LightPaletteSwatches
115import soy.iko.opencode.ui.theme.DarkPaletteSwatches
116import soy.iko.opencode.ui.theme.AmoledPaletteSwatches
117import soy.iko.opencode.util.runCatchingCancellable
118import kotlin.math.roundToInt
120@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
123 container: AppContainer,
125 onManageServers: () -> Unit,
126 onOpenDiagnostics: () -> Unit,
127 onOpenUsage: () -> Unit = {},
128 onOpenMcp: () -> Unit = {},
129 onEditProfile: ((String) -> Unit)? = null,
131 val scope = rememberCoroutineScope()
132 val snackbar = remember { SnackbarHostState() }
133 // Combine the three settings into a single nullable state so the appearance section
134 // renders only after the persisted values have loaded, avoiding a brief flash of
135 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
136 // Wrapped in remember so the combined Flow isn't re-created on every recomposition
137 // (which would cancel and relaunch the DataStore collection each time — see the
138 // connectionStateFlow below, remembered for the same reason).
139 val settingsFlow = remember(container) {
141 container.settingsStore.themeMode,
142 container.settingsStore.dynamicColor,
143 container.settingsStore.sendOnEnter,
144 container.settingsStore.appLock,
145 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
147 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
148 // Re-check biometric availability whenever the screen resumes. A user who enrolled a
149 // fingerprint in system settings then returned here would otherwise still see "unavailable"
150 // (remember with no key caches the first check forever). LifecycleResumeEffect bumps a tick
151 // on each ON_RESUME; keying the availability check on it forces a recompute.
152 val lifecycleOwner = LocalLifecycleOwner.current
153 var biometricCheckTick by remember { mutableStateOf(0) }
154 LifecycleResumeEffect(Unit, lifecycleOwner) {
158 // Text/display prefs are collected separately from [settingsFlow] (kotlinx combine
159 // tops out at 5 typed flows) and render independently — they don't need the same
160 // load-gate as the appearance block.
161 val chatTextScale by container.settingsStore.chatTextScale
162 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
163 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
164 val compactSpacing by container.settingsStore.compactMessageSpacing
165 .collectAsStateWithLifecycle(initialValue = false)
166 val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
167 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
168 val hapticsEnabled by container.settingsStore.hapticsEnabled.collectAsStateWithLifecycle(initialValue = true)
169 val reducedMotion by container.settingsStore.reducedMotion.collectAsStateWithLifecycle(initialValue = false)
170 val languageOverride by container.settingsStore.languageOverride.collectAsStateWithLifecycle(initialValue = "")
171 val notifRunComplete by container.settingsStore.notifRunComplete.collectAsStateWithLifecycle(initialValue = true)
172 val notifPermission by container.settingsStore.notifPermission.collectAsStateWithLifecycle(initialValue = true)
173 val notifError by container.settingsStore.notifError.collectAsStateWithLifecycle(initialValue = true)
174 val swipeLeftAction by container.settingsStore.swipeLeftAction.collectAsStateWithLifecycle(initialValue = "DELETE")
175 val swipeRightAction by container.settingsStore.swipeRightAction.collectAsStateWithLifecycle(initialValue = "ARCHIVE")
176 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
177 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
178 // SSE connection state so the Settings screen can show a dropped stream (the
179 // ConnectionBanner surfaces this on the chat and session screens; without it here,
180 // a user who opens Settings during a reconnect wouldn't know the stream dropped).
181 // Wrapped in remember so the Flow operator isn't re-created every recomposition
182 // (which would reset collectAsStateWithLifecycle and cause flicker).
183 val connectionStateFlow = remember {
184 container.activeConnection
185 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
187 val connectionState by connectionStateFlow
188 .collectAsStateWithLifecycle(initialValue = EventStreamClient.ConnectionState.Disconnected)
189 val isOnline by container.isOnline.collectAsStateWithLifecycle()
190 // Reconnect-attempt count for the banner's "Reconnecting (attempt N)…" label.
191 @OptIn(ExperimentalCoroutinesApi::class)
192 val reconnectAttempts by remember {
193 container.activeConnection
194 .flatMapLatest { it?.events?.reconnectAttempts ?: kotlinx.coroutines.flow.flowOf(0) }
195 }.collectAsStateWithLifecycle(initialValue = 0)
196 val context = LocalContext.current
197 // Crash count badge: surface that there are reports to look at without making the
198 // user open the screen to find out.
199 val crashLogger = remember { CrashLogger.get(context) }
200 val crashReports by crashLogger.reports.collectAsStateWithLifecycle()
201 val crashCount = crashReports.size
202 val unknownVersion = stringResource(R.string.unknown_version)
203 val versionName = remember {
205 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
206 context.packageManager.getPackageInfo(
208 PackageManager.PackageInfoFlags.of(0),
211 @Suppress("DEPRECATION")
212 context.packageManager.getPackageInfo(context.packageName, 0).versionName
214 }.getOrNull() ?: unknownVersion
217 // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
218 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
219 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
220 var includePasswords by rememberSaveable { mutableStateOf(false) }
221 // Settings search/filter query. rememberSaveable so the filter survives config changes
222 // (rotation, theme switch) — the same reason the backup toggle below is saved.
223 var settingsQuery by rememberSaveable { mutableStateOf("") }
224 // True while an export or import is in flight. Used to disable both NavRow triggers so a
225 // double-tap (or a slow device) can't launch a second file picker or start a second
226 // operation while the first is still reading/writing. Mirrors the busy-flag pattern used
227 // by other slow actions in the app.
228 var backupBusy by remember { mutableStateOf(false) }
229 // Import overwrites all profiles/settings with no undo, so a picked file is staged here
230 // and only applied once the user confirms the replace dialog below. Export with passwords
231 // included warns first (plaintext credentials leave the app).
232 var pendingImportUri by remember { mutableStateOf<android.net.Uri?>(null) }
233 var showExportPasswordWarning by rememberSaveable { mutableStateOf(false) }
234 val exportedMsg = stringResource(R.string.backup_exported)
235 val exportFailedMsg = stringResource(R.string.backup_export_failed)
236 val importedMsg = stringResource(R.string.backup_imported)
237 val restartLabel = stringResource(R.string.restart)
238 val couldNotOpenLinkMsg = stringResource(R.string.could_not_open_link)
239 val importFailedMsg = stringResource(R.string.backup_import_failed)
240 val exportLauncher = rememberLauncherForActivityResult(
241 ActivityResultContracts.CreateDocument("application/json"),
243 if (uri == null) return@rememberLauncherForActivityResult
247 val ok = runCatchingCancellable {
248 val text = container.backupManager.export(includePasswords)
249 context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
250 ?: error("no output stream")
252 snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
258 val importLauncher = rememberLauncherForActivityResult(
259 ActivityResultContracts.OpenDocument(),
261 if (uri == null) return@rememberLauncherForActivityResult
262 // Stage the file; the actual destructive import waits on the confirm dialog.
263 pendingImportUri = uri
265 // Timestamped export name so successive backups don't overwrite one another when the user
266 // exports more than once (e.g. before and after a change).
267 fun backupFilename(): String {
268 val now = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US)
269 .format(java.util.Date())
270 return "opencode-backup-$now.json"
273 // TopAppBar scroll behavior: collapse/raise the app bar as the user scrolls the
274 // settings list, matching the standard M3 large-screen affordance. Without this the
275 // top bar is static and doesn't lift on scroll, missing a familiar M3 pattern.
276 val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
280 title = { Text(stringResource(R.string.settings)) },
282 IconButton(onClick = onBack) {
283 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
286 scrollBehavior = scrollBehavior,
289 snackbarHost = { SnackbarHost(snackbar) },
295 .wrapContentWidth(Alignment.CenterHorizontally)
296 .widthIn(max = 600.dp)
297 .verticalScroll(rememberScrollState())
298 // Wire the scroll behavior so the TopAppBar collapses/lifts as this
299 // column scrolls. nestedScroll connects the child scroll to the parent
300 // app bar's scroll behavior.
301 .nestedScroll(scrollBehavior.nestedScrollConnection)
304 // Prominent connection banner at the top (matching every other content screen)
305 // so a user who opens Settings during a connection failure sees it immediately
306 // instead of having to scroll to the Connection section. The inline Connection
307 // section below remains as a detailed view.
309 state = connectionState,
311 onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
312 // Offer "Edit credentials" on an auth failure so a user landing in Settings
313 // during a 401/403 can fix the offending profile directly instead of only
314 // seeing "Retry now" (which would re-fail with the same bad credentials).
315 // Mirrors the recovery affordance ChatScreen and SessionListScreen surface.
316 onEditCredentials = onEditProfile?.let { cb -> activeProfile?.id?.let { id -> { cb(id) } } },
317 reconnectAttempts = reconnectAttempts,
320 // Resolve section-header strings once so the filter logic below can match
321 // against them without re-resolving per recomposition. These mirror the
322 // Text() headers rendered further down.
323 val appearanceHeader = stringResource(R.string.appearance)
324 val textDisplayHeader = stringResource(R.string.text_display)
325 val motionHeader = stringResource(R.string.settings_motion)
326 val notificationsHeader = stringResource(R.string.settings_notifications)
327 val swipeActionsHeader = stringResource(R.string.settings_swipe_actions)
328 val connectionHeader = stringResource(R.string.connection)
329 val backupHeader = stringResource(R.string.backup_restore)
330 val aboutHeader = stringResource(R.string.about)
331 // Row label/description strings used for filtering. Kept in the same order as the
332 // rows appear, so a future editor can line them up with the rows below.
333 val dynamicColorLabel = stringResource(R.string.dynamic_color)
334 val dynamicColorDesc = stringResource(R.string.dynamic_color_desc)
335 val sendOnEnterLabel = stringResource(R.string.send_on_enter)
336 val sendOnEnterDesc = stringResource(R.string.send_on_enter_desc)
337 val appLockLabel = stringResource(R.string.app_lock)
338 val appLockDesc = stringResource(R.string.app_lock_desc)
339 val wrapCodeLabel = stringResource(R.string.wrap_code_blocks)
340 val wrapCodeDesc = stringResource(R.string.wrap_code_blocks_desc)
341 val compactSpacingLabel = stringResource(R.string.compact_message_spacing)
342 val compactSpacingDesc = stringResource(R.string.compact_message_spacing_desc)
343 val hapticsLabel = stringResource(R.string.settings_haptics)
344 val hapticsDesc = stringResource(R.string.settings_haptics_desc)
345 val reducedMotionLabel = stringResource(R.string.settings_reduced_motion)
346 val reducedMotionDesc = stringResource(R.string.settings_reduced_motion_desc)
347 val languageLabel = stringResource(R.string.settings_language)
348 val languageDesc = stringResource(R.string.settings_language_desc)
349 val notifRunCompleteLabel = stringResource(R.string.settings_notif_run_complete)
350 val notifRunCompleteDesc = stringResource(R.string.settings_notif_run_complete_desc)
351 val notifPermissionLabel = stringResource(R.string.settings_notif_permission)
352 val notifPermissionDesc = stringResource(R.string.settings_notif_permission_desc)
353 val notifErrorLabel = stringResource(R.string.settings_notif_error)
354 val notifErrorDesc = stringResource(R.string.settings_notif_error_desc)
355 val swipeLeftLabel = stringResource(R.string.settings_swipe_left)
356 val swipeRightLabel = stringResource(R.string.settings_swipe_right)
357 val manageServersLabel = stringResource(R.string.manage_servers)
358 val usageLabel = stringResource(R.string.usage_title)
359 val mcpLabel = stringResource(R.string.mcp_servers)
360 val notifSettingsLabel = stringResource(R.string.notification_settings)
361 val backupIncludePasswordsLabel = stringResource(R.string.backup_include_passwords)
362 val backupIncludePasswordsDesc = stringResource(R.string.backup_include_passwords_desc)
363 val exportBackupLabel = stringResource(R.string.export_backup)
364 val importBackupLabel = stringResource(R.string.import_backup)
365 val diagnosticsLabel = stringResource(R.string.diagnostics)
366 val sourceCodeLabel = stringResource(R.string.source_code)
367 val reportIssueLabel = stringResource(R.string.report_issue)
368 val chatTextSizeDesc = stringResource(R.string.chat_text_size_desc)
369 val backupDescText = stringResource(R.string.backup_desc)
370 val aboutDescText = stringResource(R.string.about_desc)
372 // Per-section filter state. For each section we compute:
373 // - headerMatches: the section header itself matches the query
374 // - anyRowMatches: at least one row in the section matches
375 // A section is shown when either is true (or when the query is empty).
376 // A row is shown when the header matches alone (browse the whole matched section),
377 // or when the row itself matches.
378 val appearanceHeaderMatches = matchesQuery(settingsQuery, appearanceHeader)
379 val appearanceAnyRowMatches = matchesQuery(
381 dynamicColorLabel, dynamicColorDesc,
382 sendOnEnterLabel, sendOnEnterDesc,
383 appLockLabel, appLockDesc,
385 val showAppearance = appearanceHeaderMatches || appearanceAnyRowMatches
386 val textDisplayHeaderMatches = matchesQuery(settingsQuery, textDisplayHeader)
387 val textDisplayAnyRowMatches = matchesQuery(
389 wrapCodeLabel, wrapCodeDesc,
390 compactSpacingLabel, compactSpacingDesc,
393 val showTextDisplay = textDisplayHeaderMatches || textDisplayAnyRowMatches
394 val motionHeaderMatches = matchesQuery(settingsQuery, motionHeader)
395 val motionAnyRowMatches = matchesQuery(
397 hapticsLabel, hapticsDesc,
398 reducedMotionLabel, reducedMotionDesc,
399 languageLabel, languageDesc,
401 val showMotion = motionHeaderMatches || motionAnyRowMatches
402 val notificationsHeaderMatches = matchesQuery(settingsQuery, notificationsHeader)
403 val notificationsAnyRowMatches = matchesQuery(
405 notifRunCompleteLabel, notifRunCompleteDesc,
406 notifPermissionLabel, notifPermissionDesc,
407 notifErrorLabel, notifErrorDesc,
409 val showNotifications = notificationsHeaderMatches || notificationsAnyRowMatches
410 val swipeHeaderMatches = matchesQuery(settingsQuery, swipeActionsHeader)
411 val swipeAnyRowMatches = matchesQuery(
416 val showSwipeActions = swipeHeaderMatches || swipeAnyRowMatches
417 val connectionHeaderMatches = matchesQuery(settingsQuery, connectionHeader)
418 val connectionAnyRowMatches = matchesQuery(
425 val showConnection = connectionHeaderMatches || connectionAnyRowMatches
426 val backupHeaderMatches = matchesQuery(settingsQuery, backupHeader)
427 val backupAnyRowMatches = matchesQuery(
430 backupIncludePasswordsLabel, backupIncludePasswordsDesc,
434 val showBackup = backupHeaderMatches || backupAnyRowMatches
435 val aboutHeaderMatches = matchesQuery(settingsQuery, aboutHeader)
436 val aboutAnyRowMatches = matchesQuery(
443 val showAbout = aboutHeaderMatches || aboutAnyRowMatches
445 val keyboardController = LocalSoftwareKeyboardController.current
447 value = settingsQuery,
448 onValueChange = { settingsQuery = it },
451 .padding(bottom = 8.dp)
452 .testTag("settings_search"),
453 label = { Text(stringResource(R.string.search_settings)) },
454 placeholder = { Text(stringResource(R.string.search_settings)) },
455 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
456 trailingIcon = if (settingsQuery.isNotEmpty()) {
458 IconButton(onClick = { settingsQuery = "" }) {
459 Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.clear_search))
464 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
465 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
468 if (showAppearance) {
471 style = MaterialTheme.typography.titleSmall,
472 color = MaterialTheme.colorScheme.primary,
473 modifier = Modifier.semantics { heading() },
476 ThemeMode.entries.forEach { mode ->
477 // Theme rows have no description text; match against the mode label only.
478 // ThemeRow renders the label internally via stringResource, so resolve it
479 // here for the filter check.
480 val modeLabel = when (mode) {
481 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
482 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
483 ThemeMode.DARK -> stringResource(R.string.theme_dark)
484 ThemeMode.AMOLED -> stringResource(R.string.theme_amoled)
486 if (rowVisible(settingsQuery, appearanceHeaderMatches, appearanceAnyRowMatches, modeLabel)) {
489 selected = s.themeMode == mode,
490 // When Material You is active the static Tokyo Night swatches would be a
491 // lie, so ThemeRow hides them and notes that system colors are in use.
492 usingSystemColors = s.dynamicColor && dynamicColorAvailable,
493 onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
498 if (dynamicColorAvailable && rowVisible(
500 appearanceHeaderMatches,
501 appearanceAnyRowMatches,
502 dynamicColorLabel, dynamicColorDesc,
505 Spacer(Modifier.size(8.dp))
506 // Dynamic color (Material You) only works on Android 12+, so hide the toggle
507 // on older devices instead of offering a control that silently does nothing.
508 val amoledActive = s.themeMode == ThemeMode.AMOLED
512 .defaultMinSize(minHeight = 48.dp)
514 value = s.dynamicColor,
515 enabled = !amoledActive,
516 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
519 .padding(vertical = 8.dp),
520 verticalAlignment = Alignment.CenterVertically,
522 Column(modifier = Modifier.weight(1f)) {
523 Text(dynamicColorLabel, style = MaterialTheme.typography.bodyLarge)
526 style = MaterialTheme.typography.bodySmall,
527 color = MaterialTheme.colorScheme.onSurfaceVariant,
531 stringResource(R.string.dynamic_color_disabled_amoled),
532 style = MaterialTheme.typography.bodySmall,
533 color = MaterialTheme.colorScheme.onSurfaceVariant,
538 checked = s.dynamicColor,
539 onCheckedChange = null,
540 enabled = !amoledActive,
545 // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
548 appearanceHeaderMatches,
549 appearanceAnyRowMatches,
550 sendOnEnterLabel, sendOnEnterDesc,
553 Spacer(Modifier.size(4.dp))
557 .defaultMinSize(minHeight = 48.dp)
559 value = s.sendOnEnter,
560 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
563 .padding(vertical = 8.dp),
564 verticalAlignment = Alignment.CenterVertically,
566 Column(modifier = Modifier.weight(1f)) {
567 Text(sendOnEnterLabel, style = MaterialTheme.typography.bodyLarge)
570 style = MaterialTheme.typography.bodySmall,
571 color = MaterialTheme.colorScheme.onSurfaceVariant,
575 checked = s.sendOnEnter,
576 onCheckedChange = null,
581 // App lock: gate on device capability. Always allow turning it *off* (in case
582 // the enrolled biometric was later removed), but only allow turning it on when
583 // a biometric or device credential is actually available.
586 appearanceHeaderMatches,
587 appearanceAnyRowMatches,
588 appLockLabel, appLockDesc,
591 val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
592 val appLockToggleable = appLockAvailable || s.appLock
593 Spacer(Modifier.size(4.dp))
597 .defaultMinSize(minHeight = 48.dp)
600 enabled = appLockToggleable,
601 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
604 .padding(vertical = 8.dp),
605 verticalAlignment = Alignment.CenterVertically,
607 Column(modifier = Modifier.weight(1f)) {
608 Text(appLockLabel, style = MaterialTheme.typography.bodyLarge)
610 stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
611 style = MaterialTheme.typography.bodySmall,
612 color = MaterialTheme.colorScheme.onSurfaceVariant,
617 onCheckedChange = null,
618 enabled = appLockToggleable,
622 AppLockReLockSelector(
623 seconds = appLockReLockSeconds,
625 scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
632 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
633 contentAlignment = Alignment.Center,
635 CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
640 if (showAppearance) {
641 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
644 if (showTextDisplay) {
647 style = MaterialTheme.typography.titleSmall,
648 color = MaterialTheme.colorScheme.primary,
649 modifier = Modifier.semantics { heading() },
651 if (rowVisible(settingsQuery, textDisplayHeaderMatches, textDisplayAnyRowMatches, chatTextSizeDesc)) {
653 scale = chatTextScale,
654 onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
659 textDisplayHeaderMatches,
660 textDisplayAnyRowMatches,
661 wrapCodeLabel, wrapCodeDesc,
664 Spacer(Modifier.size(4.dp))
668 .defaultMinSize(minHeight = 48.dp)
671 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
674 .padding(vertical = 8.dp),
675 verticalAlignment = Alignment.CenterVertically,
677 Column(modifier = Modifier.weight(1f)) {
678 Text(wrapCodeLabel, style = MaterialTheme.typography.bodyLarge)
681 style = MaterialTheme.typography.bodySmall,
682 color = MaterialTheme.colorScheme.onSurfaceVariant,
685 Switch(checked = codeWrap, onCheckedChange = null)
689 // Compact message spacing: a power-user toggle to fit more conversation on screen.
692 textDisplayHeaderMatches,
693 textDisplayAnyRowMatches,
694 compactSpacingLabel, compactSpacingDesc,
697 Spacer(Modifier.size(4.dp))
701 .defaultMinSize(minHeight = 48.dp)
703 value = compactSpacing,
704 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
707 .padding(vertical = 8.dp),
708 verticalAlignment = Alignment.CenterVertically,
710 Column(modifier = Modifier.weight(1f)) {
711 Text(compactSpacingLabel, style = MaterialTheme.typography.bodyLarge)
714 style = MaterialTheme.typography.bodySmall,
715 color = MaterialTheme.colorScheme.onSurfaceVariant,
718 Switch(checked = compactSpacing, onCheckedChange = null)
723 if (showTextDisplay) {
724 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
727 // Motion & feedback: in-app toggles for haptics and reduced motion. The app
728 // already honors the system Developer Options animator scale for reduced motion,
729 // but this gives users an in-app control without disabling motion OS-wide.
730 // Haptics default on (the existing behavior); an in-app toggle lets users
731 // disable them without turning off system vibration.
735 style = MaterialTheme.typography.titleSmall,
736 color = MaterialTheme.colorScheme.primary,
737 modifier = Modifier.semantics { heading() },
739 if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, hapticsLabel, hapticsDesc)) {
740 val hapticsScope = rememberCoroutineScope()
744 .defaultMinSize(minHeight = 48.dp)
745 .clickable(role = Role.Switch) {
746 hapticsScope.launch {
747 container.settingsStore.setHapticsEnabled(!hapticsEnabled)
750 .padding(vertical = 8.dp),
751 verticalAlignment = Alignment.CenterVertically,
753 Column(modifier = Modifier.weight(1f)) {
757 style = MaterialTheme.typography.bodySmall,
758 color = MaterialTheme.colorScheme.onSurfaceVariant,
761 Switch(checked = hapticsEnabled, onCheckedChange = null)
768 reducedMotionLabel, reducedMotionDesc,
771 val motionScope = rememberCoroutineScope()
775 .defaultMinSize(minHeight = 48.dp)
776 .clickable(role = Role.Switch) {
778 container.settingsStore.setReducedMotion(!reducedMotion)
781 .padding(vertical = 8.dp),
782 verticalAlignment = Alignment.CenterVertically,
784 Column(modifier = Modifier.weight(1f)) {
785 Text(reducedMotionLabel)
788 style = MaterialTheme.typography.bodySmall,
789 color = MaterialTheme.colorScheme.onSurfaceVariant,
792 Switch(checked = reducedMotion, onCheckedChange = null)
795 // Language override: an in-app language picker so a user can use the app in a
796 // different language without changing the system locale. Uses Android 13's
797 // per-app language API; on older versions a restart is needed.
798 if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, languageLabel, languageDesc)) {
799 val langScope = rememberCoroutineScope()
803 .defaultMinSize(minHeight = 48.dp)
804 .padding(vertical = 8.dp),
805 verticalAlignment = Alignment.CenterVertically,
807 val langOptions = remember {
809 "" to R.string.settings_language_system,
810 "en" to R.string.language_english,
811 "es" to R.string.language_spanish,
814 Column(modifier = Modifier.weight(1f)) {
816 val langSubtitle = if (languageOverride.isEmpty()) languageDesc
817 else langOptions.firstOrNull { it.first == languageOverride }?.let { stringResource(it.second) }
821 style = MaterialTheme.typography.bodySmall,
822 color = MaterialTheme.colorScheme.onSurfaceVariant,
825 var langMenu by remember { mutableStateOf(false) }
827 TextButton(onClick = { langMenu = true }) {
829 langOptions.firstOrNull { it.first == languageOverride }?.let { stringResource(it.second) }
830 ?: stringResource(R.string.settings_language_system),
833 DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
834 langOptions.forEach { (code, labelRes) ->
836 text = { Text(stringResource(labelRes)) },
840 container.settingsStore.setLanguageOverride(code)
852 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
855 // Notifications: granular toggles for each notification type, so a user can
856 // mute specific types in-app (in addition to the OS channel controls). Each
857 // toggle gates whether the app posts that notification type at all.
858 if (showNotifications) {
861 style = MaterialTheme.typography.titleSmall,
862 color = MaterialTheme.colorScheme.primary,
863 modifier = Modifier.semantics { heading() },
867 notificationsHeaderMatches,
868 notificationsAnyRowMatches,
869 notifRunCompleteLabel, notifRunCompleteDesc,
872 val notifScope = rememberCoroutineScope()
876 .defaultMinSize(minHeight = 48.dp)
877 .clickable(role = Role.Switch) {
878 notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
880 .padding(vertical = 8.dp),
881 verticalAlignment = Alignment.CenterVertically,
883 Column(modifier = Modifier.weight(1f)) {
884 Text(notifRunCompleteLabel)
886 notifRunCompleteDesc,
887 style = MaterialTheme.typography.bodySmall,
888 color = MaterialTheme.colorScheme.onSurfaceVariant,
891 Switch(checked = notifRunComplete, onCheckedChange = null)
896 notificationsHeaderMatches,
897 notificationsAnyRowMatches,
898 notifPermissionLabel, notifPermissionDesc,
901 val notifScope = rememberCoroutineScope()
905 .defaultMinSize(minHeight = 48.dp)
906 .clickable(role = Role.Switch) {
907 notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
909 .padding(vertical = 8.dp),
910 verticalAlignment = Alignment.CenterVertically,
912 Column(modifier = Modifier.weight(1f)) {
913 Text(notifPermissionLabel)
916 style = MaterialTheme.typography.bodySmall,
917 color = MaterialTheme.colorScheme.onSurfaceVariant,
920 Switch(checked = notifPermission, onCheckedChange = null)
925 notificationsHeaderMatches,
926 notificationsAnyRowMatches,
927 notifErrorLabel, notifErrorDesc,
930 val notifScope = rememberCoroutineScope()
934 .defaultMinSize(minHeight = 48.dp)
935 .clickable(role = Role.Switch) {
936 notifScope.launch { container.settingsStore.setNotifError(!notifError) }
938 .padding(vertical = 8.dp),
939 verticalAlignment = Alignment.CenterVertically,
941 Column(modifier = Modifier.weight(1f)) {
942 Text(notifErrorLabel)
945 style = MaterialTheme.typography.bodySmall,
946 color = MaterialTheme.colorScheme.onSurfaceVariant,
949 Switch(checked = notifError, onCheckedChange = null)
954 if (showNotifications) {
955 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
958 // Swipe actions: remappable left/right swipe on session list rows. Defaults
959 // match the prior hardcoded behavior (left=delete, right=archive). NONE
960 // disables the swipe entirely.
961 if (showSwipeActions) {
964 style = MaterialTheme.typography.titleSmall,
965 color = MaterialTheme.colorScheme.primary,
966 modifier = Modifier.semantics { heading() },
968 val swipeScope = rememberCoroutineScope()
969 val swipeOptions = remember {
971 SwipeAction.DELETE to R.string.swipe_action_delete,
972 SwipeAction.ARCHIVE to R.string.swipe_action_archive,
973 SwipeAction.MARK_READ to R.string.swipe_action_mark_read,
974 SwipeAction.NONE to R.string.swipe_action_none,
977 var swipeLeftMenu by remember { mutableStateOf(false) }
978 var swipeRightMenu by remember { mutableStateOf(false) }
979 val swipeLeftParsed = remember(swipeLeftAction) {
980 runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
982 val swipeRightParsed = remember(swipeRightAction) {
983 runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
985 if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeLeftLabel)) {
989 .defaultMinSize(minHeight = 48.dp)
990 .padding(vertical = 8.dp),
991 verticalAlignment = Alignment.CenterVertically,
993 Text(swipeLeftLabel, modifier = Modifier.weight(1f))
995 TextButton(onClick = { swipeLeftMenu = true }) {
996 Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
998 DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
999 swipeOptions.forEach { (action, label) ->
1001 text = { Text(stringResource(label)) },
1003 swipeLeftMenu = false
1004 swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
1012 if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeRightLabel)) {
1016 .defaultMinSize(minHeight = 48.dp)
1017 .padding(vertical = 8.dp),
1018 verticalAlignment = Alignment.CenterVertically,
1020 Text(swipeRightLabel, modifier = Modifier.weight(1f))
1022 TextButton(onClick = { swipeRightMenu = true }) {
1023 Text(stringResource(swipeOptions.first { it.first == swipeRightParsed }.second))
1025 DropdownMenu(expanded = swipeRightMenu, onDismissRequest = { swipeRightMenu = false }) {
1026 swipeOptions.forEach { (action, label) ->
1028 text = { Text(stringResource(label)) },
1030 swipeRightMenu = false
1031 swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
1041 if (showSwipeActions) {
1042 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1045 if (showConnection) {
1048 style = MaterialTheme.typography.titleSmall,
1049 color = MaterialTheme.colorScheme.primary,
1050 modifier = Modifier.semantics { heading() },
1052 // The connection status block (profile label + URL + SSE state + retry) has no
1053 // single stable label to match against, so show it whenever the section is visible.
1054 if (activeProfile != null) {
1055 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
1057 activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
1058 style = MaterialTheme.typography.bodySmall,
1059 color = MaterialTheme.colorScheme.onSurfaceVariant,
1061 // Surface the SSE stream state so a user who opens Settings during a
1062 // reconnect saw it (the ConnectionBanner on other screens handles this
1063 // elsewhere). Only show non-Connected states to avoid clutter. On a hard
1064 // failure offer an inline Retry — without it a user who opened Settings
1065 // during a failure is stuck on an error message with no recovery path
1066 // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
1067 // host it). Retry forces an SSE reconnect, which re-seeds from REST.
1068 val stateText = when (connectionState) {
1069 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
1070 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
1071 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
1072 EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
1073 EventStreamClient.ConnectionState.Connected -> null
1077 modifier = Modifier.padding(top = 4.dp),
1078 verticalAlignment = Alignment.CenterVertically,
1080 val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
1081 connectionState == EventStreamClient.ConnectionState.AuthFailed
1084 style = MaterialTheme.typography.bodySmall,
1085 color = if (isHardFailure)
1086 MaterialTheme.colorScheme.error
1087 else MaterialTheme.colorScheme.onSurfaceVariant,
1089 if (isHardFailure) {
1091 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
1092 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1093 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
1094 ) { Text(stringResource(R.string.retry_now)) }
1100 stringResource(R.string.not_connected),
1101 modifier = Modifier.padding(top = 8.dp),
1102 color = MaterialTheme.colorScheme.onSurfaceVariant,
1105 if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, manageServersLabel)) {
1109 .defaultMinSize(minHeight = 48.dp)
1110 .padding(top = 8.dp)
1111 .clickable(role = Role.Button) { onManageServers() }
1112 .padding(vertical = 8.dp),
1113 verticalAlignment = Alignment.CenterVertically,
1115 Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
1116 Text(manageServersLabel, modifier = Modifier.weight(1f).padding(start = 8.dp))
1117 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
1120 if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, usageLabel)) {
1122 icon = Icons.Filled.QueryStats,
1124 enabled = activeProfile != null,
1125 onClick = onOpenUsage,
1128 if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, mcpLabel)) {
1130 icon = Icons.Filled.Hub,
1132 enabled = activeProfile != null,
1133 onClick = onOpenMcp,
1136 if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, notifSettingsLabel)) {
1138 icon = Icons.Filled.Notifications,
1139 label = notifSettingsLabel,
1143 val opened = runCatchingCancellable {
1144 val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
1145 .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
1146 context.startActivity(intent)
1148 if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
1155 if (showConnection) {
1156 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1162 style = MaterialTheme.typography.titleSmall,
1163 color = MaterialTheme.colorScheme.primary,
1164 modifier = Modifier.semantics { heading() },
1166 if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, backupDescText)) {
1169 style = MaterialTheme.typography.bodySmall,
1170 color = MaterialTheme.colorScheme.onSurfaceVariant,
1171 modifier = Modifier.padding(top = 4.dp),
1176 backupHeaderMatches,
1177 backupAnyRowMatches,
1178 backupIncludePasswordsLabel, backupIncludePasswordsDesc,
1184 .defaultMinSize(minHeight = 48.dp)
1186 value = includePasswords,
1187 onValueChange = { includePasswords = it },
1190 .padding(vertical = 8.dp),
1191 verticalAlignment = Alignment.CenterVertically,
1193 Column(modifier = Modifier.weight(1f)) {
1194 Text(backupIncludePasswordsLabel, style = MaterialTheme.typography.bodyLarge)
1196 backupIncludePasswordsDesc,
1197 style = MaterialTheme.typography.bodySmall,
1198 color = MaterialTheme.colorScheme.onSurfaceVariant,
1201 Switch(checked = includePasswords, onCheckedChange = null)
1204 if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, exportBackupLabel)) {
1206 icon = Icons.Filled.Upload,
1207 label = exportBackupLabel,
1208 enabled = !backupBusy,
1209 // Warn before writing plaintext credentials out of the app; confirm then launches
1210 // the file picker. Without passwords, export straight away.
1212 if (includePasswords) showExportPasswordWarning = true
1213 else runCatching { exportLauncher.launch(backupFilename()) }
1217 if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, importBackupLabel)) {
1219 icon = Icons.Filled.Download,
1220 label = importBackupLabel,
1221 enabled = !backupBusy,
1224 importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
1229 // Surface an inline progress indicator while a backup write/read is in flight so
1230 // the user gets immediate feedback that the (potentially slow on a big file)
1231 // operation is running, and isn't tempted to tap again.
1233 LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
1238 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1244 style = MaterialTheme.typography.titleSmall,
1245 color = MaterialTheme.colorScheme.primary,
1246 modifier = Modifier.semantics { heading() },
1248 // The version line is the About section's main content; show it whenever the
1249 // section is visible (it has no separate label to filter on).
1251 stringResource(R.string.about_version, versionName),
1252 style = MaterialTheme.typography.bodyMedium,
1253 modifier = Modifier.padding(top = 8.dp),
1255 if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, aboutDescText)) {
1258 style = MaterialTheme.typography.bodySmall,
1259 color = MaterialTheme.colorScheme.onSurfaceVariant,
1260 modifier = Modifier.padding(top = 4.dp),
1264 if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, diagnosticsLabel)) {
1265 Spacer(Modifier.size(16.dp))
1269 .defaultMinSize(minHeight = 48.dp)
1270 .clickable(role = Role.Button) { onOpenDiagnostics() }
1271 .padding(vertical = 8.dp),
1272 verticalAlignment = Alignment.CenterVertically,
1274 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
1275 Text(diagnosticsLabel, modifier = Modifier.weight(1f).padding(start = 8.dp))
1276 // Badge with the crash count so the user can tell at a glance whether
1277 // there's something worth investigating. Uses M3 BadgedBox+Badge for
1278 // consistency with the nav rail's badge (which already uses the real M3
1279 // components) instead of a hand-rolled Box.
1280 if (crashCount > 0) {
1284 val overflow = stringResource(R.string.crash_count_overflow)
1285 Text(if (crashCount > 99) overflow else crashCount.toString())
1289 Spacer(Modifier.size(8.dp))
1291 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
1294 // Discoverability links from the About section: source code and issue tracker.
1295 // Opens in the browser via ACTION_VIEW; a device without a browser (e.g. a de-Googled
1296 // or work-profile split) silently no-ops otherwise, so surface a snackbar on failure.
1297 if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, sourceCodeLabel)) {
1299 icon = Icons.Filled.Code,
1300 label = sourceCodeLabel,
1304 context.startActivity(
1305 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
1307 }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
1311 if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, reportIssueLabel)) {
1313 icon = Icons.Filled.Feedback,
1314 label = reportIssueLabel,
1318 context.startActivity(
1319 Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
1321 }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
1329 // Destructive-import confirmation: applies the staged file only on Replace.
1330 pendingImportUri?.let { uri ->
1332 onDismissRequest = { pendingImportUri = null },
1333 title = { Text(stringResource(R.string.import_confirm_title)) },
1334 text = { Text(stringResource(R.string.import_confirm_text)) },
1336 TextButton(onClick = {
1337 pendingImportUri = null
1341 val ok = runCatchingCancellable {
1342 val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
1343 ?: error("no input stream")
1344 container.backupManager.import(text)
1347 val result = snackbar.showSnackbar(
1348 message = importedMsg,
1349 actionLabel = restartLabel,
1351 if (result == SnackbarResult.ActionPerformed) {
1352 // Shut down the container (cancels the app scope, closes the
1353 // active connection, flushes stores) before restarting so
1354 // in-flight work is cleaned up rather than abruptly severed.
1355 runCatchingCancellable { container.shutdown() }
1356 // Graceful restart: relaunch the app's main activity via the
1357 // launcher intent and finish the current task, instead of
1358 // killing the process (which the system may log as a crash
1359 // and which skips normal lifecycle teardown). Falling back to
1360 // a hard kill only when no launch intent is resolvable.
1361 val pm = context.packageManager
1362 val intent = pm.getLaunchIntentForPackage(context.packageName)
1363 if (intent != null) {
1365 android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
1366 android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK,
1368 context.startActivity(intent)
1370 android.os.Process.killProcess(android.os.Process.myPid())
1374 snackbar.showSnackbar(importFailedMsg)
1380 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
1383 TextButton(onClick = { pendingImportUri = null }) { Text(stringResource(R.string.cancel)) }
1388 // Plaintext-password export warning: only launches the file picker on confirm.
1389 if (showExportPasswordWarning) {
1391 onDismissRequest = { showExportPasswordWarning = false },
1392 title = { Text(stringResource(R.string.export_passwords_title)) },
1393 text = { Text(stringResource(R.string.export_passwords_warning)) },
1395 TextButton(onClick = {
1396 showExportPasswordWarning = false
1397 runCatching { exportLauncher.launch(backupFilename()) }
1398 }) { Text(stringResource(R.string.export_backup), color = MaterialTheme.colorScheme.error) }
1401 TextButton(onClick = { showExportPasswordWarning = false }) { Text(stringResource(R.string.cancel)) }
1407/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
1410 icon: androidx.compose.ui.graphics.vector.ImageVector,
1413 onClick: () -> Unit,
1418 .defaultMinSize(minHeight = 48.dp)
1419 .clickable(enabled = enabled, role = Role.Button) { onClick() }
1420 .padding(vertical = 8.dp),
1421 verticalAlignment = Alignment.CenterVertically,
1425 contentDescription = null,
1426 tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
1430 modifier = Modifier.weight(1f).padding(start = 8.dp),
1431 color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
1434 Icons.AutoMirrored.Filled.KeyboardArrowRight,
1435 contentDescription = null,
1436 tint = MaterialTheme.colorScheme.onSurfaceVariant,
1441/** Chat text-size slider with a live sample line, a percentage readout, and a Reset.
1442 * Drives [SettingsStore.chatTextScale]; persists only when the drag settles so a DataStore
1443 * write doesn't fire on every pixel of movement. */
1445private fun ChatTextSizeControl(scale: Float, onScaleChange: (Float) -> Unit) {
1446 // Local slider state so dragging stays smooth; re-seed when the persisted value changes
1447 // (e.g. Reset here, or an external write) so the control stays in sync.
1448 var sliderValue by remember(scale) { mutableFloatStateOf(scale) }
1449 val body = MaterialTheme.typography.bodyLarge
1451 stringResource(R.string.text_size_sample),
1452 style = body.copy(fontSize = body.fontSize * sliderValue, lineHeight = body.lineHeight * sliderValue),
1453 modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
1456 stringResource(R.string.chat_text_size_desc),
1457 style = MaterialTheme.typography.bodySmall,
1458 color = MaterialTheme.colorScheme.onSurfaceVariant,
1460 Row(verticalAlignment = Alignment.CenterVertically) {
1462 value = sliderValue,
1463 onValueChange = { sliderValue = it },
1464 onValueChangeFinished = { onScaleChange(sliderValue) },
1465 valueRange = SettingsStore.MIN_CHAT_TEXT_SCALE..SettingsStore.MAX_CHAT_TEXT_SCALE,
1466 modifier = Modifier.weight(1f),
1469 stringResource(R.string.text_scale_value, (sliderValue * 100).roundToInt()),
1470 style = MaterialTheme.typography.labelLarge,
1471 textAlign = TextAlign.End,
1472 modifier = Modifier.widthIn(min = 44.dp).padding(start = 8.dp),
1477 sliderValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE
1478 onScaleChange(SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
1480 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
1481 ) { Text(stringResource(R.string.reset)) }
1482 // The app text scale multiplies on top of the OS font scale, and the effective size is
1483 // clamped so the product can't exceed the combined cap (see OpencodeApp). When the user's
1484 // system font is already large, the value they pick here would be silently reduced — so
1485 // surface that the effective size is capped rather than letting the slider appear broken.
1486 val systemFontScale = LocalDensity.current.fontScale
1487 if (systemFontScale > 0f) {
1488 val effective = (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
1489 if (effective < sliderValue) {
1491 stringResource(R.string.text_size_capped, (effective * 100).roundToInt()),
1492 style = MaterialTheme.typography.bodySmall,
1493 color = MaterialTheme.colorScheme.onSurfaceVariant,
1494 modifier = Modifier.padding(top = 2.dp),
1501private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Boolean, onSelect: () -> Unit) {
1502 // Preview swatches so the user can see what each mode looks like before selecting,
1503 // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
1504 // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
1505 val swatches: List<Color> = when (mode) {
1506 ThemeMode.SYSTEM -> {
1507 val dark = isSystemInDarkThemeStatic()
1508 if (dark) DarkSwatches else LightSwatches
1510 ThemeMode.LIGHT -> LightSwatches
1511 ThemeMode.DARK -> DarkSwatches
1512 ThemeMode.AMOLED -> AmoledSwatches
1517 .defaultMinSize(minHeight = 48.dp)
1518 .selectable(selected = selected, onClick = onSelect)
1519 .padding(vertical = 8.dp),
1520 horizontalArrangement = Arrangement.spacedBy(8.dp),
1521 verticalAlignment = Alignment.CenterVertically,
1523 RadioButton(selected = selected, onClick = null)
1526 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
1527 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
1528 ThemeMode.DARK -> stringResource(R.string.theme_dark)
1529 ThemeMode.AMOLED -> stringResource(R.string.theme_amoled)
1531 style = MaterialTheme.typography.bodyLarge,
1532 modifier = Modifier.weight(1f),
1534 if (usingSystemColors) {
1535 // Dynamic color (Material You) overrides the static palettes, so the dots would
1536 // misrepresent the actual colors — note that system colors are in use instead.
1538 stringResource(R.string.theme_using_system_colors),
1539 style = MaterialTheme.typography.labelSmall,
1540 color = MaterialTheme.colorScheme.onSurfaceVariant,
1541 textAlign = TextAlign.End,
1544 // Three small color dots previewing the palette for this mode.
1545 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
1546 swatches.forEach { color ->
1559// Palette preview dots for the theme rows. Primary/secondary/tertiary/background convey
1560// the character of each scheme at a glance without rendering a full sample surface.
1561private val DarkSwatches: List<Color> = DarkPaletteSwatches
1562private val LightSwatches: List<Color> = LightPaletteSwatches
1563private val AmoledSwatches: List<Color> = AmoledPaletteSwatches
1565// Composable-side read of the system dark setting for the "System" theme row preview.
1566// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
1567// helper; hoist it here so the ThemeRow stays a plain @Composable with no preview-time
1568// recomposition surprises.
1570private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
1573 * Case-insensitive substring match against any of [texts]. An empty query matches everything
1574 * (so the search filter is a no-op when the field is cleared). Used to gate section/row
1575 * visibility in the settings list.
1577private fun matchesQuery(query: String, vararg texts: String): Boolean {
1578 if (query.isEmpty()) return true
1579 val q = query.trim().lowercase()
1580 if (q.isEmpty()) return true
1581 return texts.any { it.lowercase().contains(q) }
1585 * Per-row visibility given the section's precomputed header/any-row match state. When the
1586 * header matched but no row did, the whole section is browsable so every row is shown;
1587 * otherwise a row shows only when its own texts match the query.
1589private fun rowVisible(
1591 headerMatches: Boolean,
1592 anyRowMatches: Boolean,
1593 vararg rowTexts: String,
1595 if (query.isEmpty()) return true
1596 if (headerMatches && !anyRowMatches) return true
1597 return matchesQuery(query, *rowTexts)
1600/** Small wrapper so the three appearance settings load atomically and the UI can gate
1601 * on a single null check instead of flashing hardcoded defaults. */
1602private data class SettingsValues(
1603 val themeMode: ThemeMode,
1604 val dynamicColor: Boolean,
1605 val sendOnEnter: Boolean,
1606 val appLock: Boolean,
1609/** Dropdown selecting how long after backgrounding the app re-locks. Only shown when app lock
1610 * is on. The grace period avoids a re-prompt on every quick app-switch (the most common reason
1611 * users disable biometric locks), while still re-locking immediately by default. */
1612@OptIn(ExperimentalMaterial3Api::class)
1614private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
1615 var expanded by remember { mutableStateOf(false) }
1616 ExposedDropdownMenuBox(
1617 expanded = expanded,
1618 onExpandedChange = { expanded = it },
1621 .padding(start = 56.dp, top = 4.dp),
1624 value = relockLabel(seconds),
1628 label = { Text(stringResource(R.string.app_lock_relock)) },
1629 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
1631 .menuAnchor(MenuAnchorType.PrimaryNotEditable, true)
1634 ExposedDropdownMenu(
1635 expanded = expanded,
1636 onDismissRequest = { expanded = false },
1638 SettingsStore.APP_LOCK_RELOCK_OPTIONS_SECONDS.forEach { option ->
1640 text = { Text(relockLabel(option)) },
1641 onClick = { expanded = false; onSelect(option) },
1649private fun relockLabel(seconds: Int): String = when {
1650 seconds <= 0 -> stringResource(R.string.app_lock_relock_immediately)
1651 seconds < 60 -> pluralStringResource(R.plurals.app_lock_relock_seconds, seconds, seconds)
1653 val mins = seconds / 60
1654 pluralStringResource(R.plurals.app_lock_relock_minutes, mins, mins)
1657 val hours = seconds / 3600
1658 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