git.iko.soy/opencode-android
on a0ce9ce help sign in
1660 lines 87 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.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)
121@Composable
122fun SettingsScreen(
123 container: AppContainer,
124 onBack: () -> Unit,
125 onManageServers: () -> Unit,
126 onOpenDiagnostics: () -> Unit,
127 onOpenUsage: () -> Unit = {},
128 onOpenMcp: () -> Unit = {},
129 onEditProfile: ((String) -> Unit)? = null,
130) {
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) {
140 combine(
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? }
146 }
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) {
155 biometricCheckTick++
156 onPauseOrDispose { }
157 }
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) }
186 }
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 {
204 runCatching {
205 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
206 context.packageManager.getPackageInfo(
207 context.packageName,
208 PackageManager.PackageInfoFlags.of(0),
209 ).versionName
210 } else {
211 @Suppress("DEPRECATION")
212 context.packageManager.getPackageInfo(context.packageName, 0).versionName
213 }
214 }.getOrNull() ?: unknownVersion
215 }
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"),
242 ) { uri ->
243 if (uri == null) return@rememberLauncherForActivityResult
244 scope.launch {
245 backupBusy = true
246 try {
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")
251 }.isSuccess
252 snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
253 } finally {
254 backupBusy = false
255 }
256 }
257 }
258 val importLauncher = rememberLauncherForActivityResult(
259 ActivityResultContracts.OpenDocument(),
260 ) { uri ->
261 if (uri == null) return@rememberLauncherForActivityResult
262 // Stage the file; the actual destructive import waits on the confirm dialog.
263 pendingImportUri = uri
264 }
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"
271 }
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()
277 Scaffold(
278 topBar = {
279 TopAppBar(
280 title = { Text(stringResource(R.string.settings)) },
281 navigationIcon = {
282 IconButton(onClick = onBack) {
283 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
284 }
285 },
286 scrollBehavior = scrollBehavior,
287 )
288 },
289 snackbarHost = { SnackbarHost(snackbar) },
290 ) { padding ->
291 Column(
292 modifier = Modifier
293 .padding(padding)
294 .fillMaxWidth()
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)
302 .padding(16.dp),
303 ) {
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.
308 ConnectionBanner(
309 state = connectionState,
310 isOnline = isOnline,
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,
318 )
319 val s = settings
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(
380 settingsQuery,
381 dynamicColorLabel, dynamicColorDesc,
382 sendOnEnterLabel, sendOnEnterDesc,
383 appLockLabel, appLockDesc,
384 )
385 val showAppearance = appearanceHeaderMatches || appearanceAnyRowMatches
386 val textDisplayHeaderMatches = matchesQuery(settingsQuery, textDisplayHeader)
387 val textDisplayAnyRowMatches = matchesQuery(
388 settingsQuery,
389 wrapCodeLabel, wrapCodeDesc,
390 compactSpacingLabel, compactSpacingDesc,
391 chatTextSizeDesc,
392 )
393 val showTextDisplay = textDisplayHeaderMatches || textDisplayAnyRowMatches
394 val motionHeaderMatches = matchesQuery(settingsQuery, motionHeader)
395 val motionAnyRowMatches = matchesQuery(
396 settingsQuery,
397 hapticsLabel, hapticsDesc,
398 reducedMotionLabel, reducedMotionDesc,
399 languageLabel, languageDesc,
400 )
401 val showMotion = motionHeaderMatches || motionAnyRowMatches
402 val notificationsHeaderMatches = matchesQuery(settingsQuery, notificationsHeader)
403 val notificationsAnyRowMatches = matchesQuery(
404 settingsQuery,
405 notifRunCompleteLabel, notifRunCompleteDesc,
406 notifPermissionLabel, notifPermissionDesc,
407 notifErrorLabel, notifErrorDesc,
408 )
409 val showNotifications = notificationsHeaderMatches || notificationsAnyRowMatches
410 val swipeHeaderMatches = matchesQuery(settingsQuery, swipeActionsHeader)
411 val swipeAnyRowMatches = matchesQuery(
412 settingsQuery,
413 swipeLeftLabel,
414 swipeRightLabel,
415 )
416 val showSwipeActions = swipeHeaderMatches || swipeAnyRowMatches
417 val connectionHeaderMatches = matchesQuery(settingsQuery, connectionHeader)
418 val connectionAnyRowMatches = matchesQuery(
419 settingsQuery,
420 manageServersLabel,
421 usageLabel,
422 mcpLabel,
423 notifSettingsLabel,
424 )
425 val showConnection = connectionHeaderMatches || connectionAnyRowMatches
426 val backupHeaderMatches = matchesQuery(settingsQuery, backupHeader)
427 val backupAnyRowMatches = matchesQuery(
428 settingsQuery,
429 backupDescText,
430 backupIncludePasswordsLabel, backupIncludePasswordsDesc,
431 exportBackupLabel,
432 importBackupLabel,
433 )
434 val showBackup = backupHeaderMatches || backupAnyRowMatches
435 val aboutHeaderMatches = matchesQuery(settingsQuery, aboutHeader)
436 val aboutAnyRowMatches = matchesQuery(
437 settingsQuery,
438 aboutDescText,
439 diagnosticsLabel,
440 sourceCodeLabel,
441 reportIssueLabel,
442 )
443 val showAbout = aboutHeaderMatches || aboutAnyRowMatches
445 val keyboardController = LocalSoftwareKeyboardController.current
446 OutlinedTextField(
447 value = settingsQuery,
448 onValueChange = { settingsQuery = it },
449 modifier = Modifier
450 .fillMaxWidth()
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()) {
457 {
458 IconButton(onClick = { settingsQuery = "" }) {
459 Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.clear_search))
460 }
461 }
462 } else null,
463 singleLine = true,
464 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
465 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
466 )
468 if (showAppearance) {
469 Text(
470 appearanceHeader,
471 style = MaterialTheme.typography.titleSmall,
472 color = MaterialTheme.colorScheme.primary,
473 modifier = Modifier.semantics { heading() },
474 )
475 if (s != null) {
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)
485 }
486 if (rowVisible(settingsQuery, appearanceHeaderMatches, appearanceAnyRowMatches, modeLabel)) {
487 ThemeRow(
488 mode = mode,
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) } } },
494 )
495 }
496 }
498 if (dynamicColorAvailable && rowVisible(
499 settingsQuery,
500 appearanceHeaderMatches,
501 appearanceAnyRowMatches,
502 dynamicColorLabel, dynamicColorDesc,
503 )
504 ) {
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
509 Row(
510 modifier = Modifier
511 .fillMaxWidth()
512 .defaultMinSize(minHeight = 48.dp)
513 .toggleable(
514 value = s.dynamicColor,
515 enabled = !amoledActive,
516 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
517 role = Role.Switch,
518 )
519 .padding(vertical = 8.dp),
520 verticalAlignment = Alignment.CenterVertically,
521 ) {
522 Column(modifier = Modifier.weight(1f)) {
523 Text(dynamicColorLabel, style = MaterialTheme.typography.bodyLarge)
524 Text(
525 dynamicColorDesc,
526 style = MaterialTheme.typography.bodySmall,
527 color = MaterialTheme.colorScheme.onSurfaceVariant,
528 )
529 if (amoledActive) {
530 Text(
531 stringResource(R.string.dynamic_color_disabled_amoled),
532 style = MaterialTheme.typography.bodySmall,
533 color = MaterialTheme.colorScheme.onSurfaceVariant,
534 )
535 }
536 }
537 Switch(
538 checked = s.dynamicColor,
539 onCheckedChange = null,
540 enabled = !amoledActive,
541 )
542 }
543 }
545 // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
546 if (rowVisible(
547 settingsQuery,
548 appearanceHeaderMatches,
549 appearanceAnyRowMatches,
550 sendOnEnterLabel, sendOnEnterDesc,
551 )
552 ) {
553 Spacer(Modifier.size(4.dp))
554 Row(
555 modifier = Modifier
556 .fillMaxWidth()
557 .defaultMinSize(minHeight = 48.dp)
558 .toggleable(
559 value = s.sendOnEnter,
560 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
561 role = Role.Switch,
562 )
563 .padding(vertical = 8.dp),
564 verticalAlignment = Alignment.CenterVertically,
565 ) {
566 Column(modifier = Modifier.weight(1f)) {
567 Text(sendOnEnterLabel, style = MaterialTheme.typography.bodyLarge)
568 Text(
569 sendOnEnterDesc,
570 style = MaterialTheme.typography.bodySmall,
571 color = MaterialTheme.colorScheme.onSurfaceVariant,
572 )
573 }
574 Switch(
575 checked = s.sendOnEnter,
576 onCheckedChange = null,
577 )
578 }
579 }
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.
584 if (rowVisible(
585 settingsQuery,
586 appearanceHeaderMatches,
587 appearanceAnyRowMatches,
588 appLockLabel, appLockDesc,
589 )
590 ) {
591 val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
592 val appLockToggleable = appLockAvailable || s.appLock
593 Spacer(Modifier.size(4.dp))
594 Row(
595 modifier = Modifier
596 .fillMaxWidth()
597 .defaultMinSize(minHeight = 48.dp)
598 .toggleable(
599 value = s.appLock,
600 enabled = appLockToggleable,
601 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
602 role = Role.Switch,
603 )
604 .padding(vertical = 8.dp),
605 verticalAlignment = Alignment.CenterVertically,
606 ) {
607 Column(modifier = Modifier.weight(1f)) {
608 Text(appLockLabel, style = MaterialTheme.typography.bodyLarge)
609 Text(
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,
613 )
614 }
615 Switch(
616 checked = s.appLock,
617 onCheckedChange = null,
618 enabled = appLockToggleable,
619 )
620 }
621 if (s.appLock) {
622 AppLockReLockSelector(
623 seconds = appLockReLockSeconds,
624 onSelect = { sec ->
625 scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
626 },
627 )
628 }
629 }
630 } else {
631 Box(
632 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
633 contentAlignment = Alignment.Center,
634 ) {
635 CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
636 }
637 }
638 }
640 if (showAppearance) {
641 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
642 }
644 if (showTextDisplay) {
645 Text(
646 textDisplayHeader,
647 style = MaterialTheme.typography.titleSmall,
648 color = MaterialTheme.colorScheme.primary,
649 modifier = Modifier.semantics { heading() },
650 )
651 if (rowVisible(settingsQuery, textDisplayHeaderMatches, textDisplayAnyRowMatches, chatTextSizeDesc)) {
652 ChatTextSizeControl(
653 scale = chatTextScale,
654 onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
655 )
656 }
657 if (rowVisible(
658 settingsQuery,
659 textDisplayHeaderMatches,
660 textDisplayAnyRowMatches,
661 wrapCodeLabel, wrapCodeDesc,
662 )
663 ) {
664 Spacer(Modifier.size(4.dp))
665 Row(
666 modifier = Modifier
667 .fillMaxWidth()
668 .defaultMinSize(minHeight = 48.dp)
669 .toggleable(
670 value = codeWrap,
671 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
672 role = Role.Switch,
673 )
674 .padding(vertical = 8.dp),
675 verticalAlignment = Alignment.CenterVertically,
676 ) {
677 Column(modifier = Modifier.weight(1f)) {
678 Text(wrapCodeLabel, style = MaterialTheme.typography.bodyLarge)
679 Text(
680 wrapCodeDesc,
681 style = MaterialTheme.typography.bodySmall,
682 color = MaterialTheme.colorScheme.onSurfaceVariant,
683 )
684 }
685 Switch(checked = codeWrap, onCheckedChange = null)
686 }
687 }
689 // Compact message spacing: a power-user toggle to fit more conversation on screen.
690 if (rowVisible(
691 settingsQuery,
692 textDisplayHeaderMatches,
693 textDisplayAnyRowMatches,
694 compactSpacingLabel, compactSpacingDesc,
695 )
696 ) {
697 Spacer(Modifier.size(4.dp))
698 Row(
699 modifier = Modifier
700 .fillMaxWidth()
701 .defaultMinSize(minHeight = 48.dp)
702 .toggleable(
703 value = compactSpacing,
704 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
705 role = Role.Switch,
706 )
707 .padding(vertical = 8.dp),
708 verticalAlignment = Alignment.CenterVertically,
709 ) {
710 Column(modifier = Modifier.weight(1f)) {
711 Text(compactSpacingLabel, style = MaterialTheme.typography.bodyLarge)
712 Text(
713 compactSpacingDesc,
714 style = MaterialTheme.typography.bodySmall,
715 color = MaterialTheme.colorScheme.onSurfaceVariant,
716 )
717 }
718 Switch(checked = compactSpacing, onCheckedChange = null)
719 }
720 }
721 }
723 if (showTextDisplay) {
724 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
725 }
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.
732 if (showMotion) {
733 Text(
734 motionHeader,
735 style = MaterialTheme.typography.titleSmall,
736 color = MaterialTheme.colorScheme.primary,
737 modifier = Modifier.semantics { heading() },
738 )
739 if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, hapticsLabel, hapticsDesc)) {
740 val hapticsScope = rememberCoroutineScope()
741 Row(
742 modifier = Modifier
743 .fillMaxWidth()
744 .defaultMinSize(minHeight = 48.dp)
745 .clickable(role = Role.Switch) {
746 hapticsScope.launch {
747 container.settingsStore.setHapticsEnabled(!hapticsEnabled)
748 }
749 }
750 .padding(vertical = 8.dp),
751 verticalAlignment = Alignment.CenterVertically,
752 ) {
753 Column(modifier = Modifier.weight(1f)) {
754 Text(hapticsLabel)
755 Text(
756 hapticsDesc,
757 style = MaterialTheme.typography.bodySmall,
758 color = MaterialTheme.colorScheme.onSurfaceVariant,
759 )
760 }
761 Switch(checked = hapticsEnabled, onCheckedChange = null)
762 }
763 }
764 if (rowVisible(
765 settingsQuery,
766 motionHeaderMatches,
767 motionAnyRowMatches,
768 reducedMotionLabel, reducedMotionDesc,
769 )
770 ) {
771 val motionScope = rememberCoroutineScope()
772 Row(
773 modifier = Modifier
774 .fillMaxWidth()
775 .defaultMinSize(minHeight = 48.dp)
776 .clickable(role = Role.Switch) {
777 motionScope.launch {
778 container.settingsStore.setReducedMotion(!reducedMotion)
779 }
780 }
781 .padding(vertical = 8.dp),
782 verticalAlignment = Alignment.CenterVertically,
783 ) {
784 Column(modifier = Modifier.weight(1f)) {
785 Text(reducedMotionLabel)
786 Text(
787 reducedMotionDesc,
788 style = MaterialTheme.typography.bodySmall,
789 color = MaterialTheme.colorScheme.onSurfaceVariant,
790 )
791 }
792 Switch(checked = reducedMotion, onCheckedChange = null)
793 }
794 }
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()
800 Row(
801 modifier = Modifier
802 .fillMaxWidth()
803 .defaultMinSize(minHeight = 48.dp)
804 .padding(vertical = 8.dp),
805 verticalAlignment = Alignment.CenterVertically,
806 ) {
807 val langOptions = remember {
808 listOf(
809 "" to R.string.settings_language_system,
810 "en" to R.string.language_english,
811 "es" to R.string.language_spanish,
812 )
813 }
814 Column(modifier = Modifier.weight(1f)) {
815 Text(languageLabel)
816 val langSubtitle = if (languageOverride.isEmpty()) languageDesc
817 else langOptions.firstOrNull { it.first == languageOverride }?.let { stringResource(it.second) }
818 ?: languageOverride
819 Text(
820 langSubtitle,
821 style = MaterialTheme.typography.bodySmall,
822 color = MaterialTheme.colorScheme.onSurfaceVariant,
823 )
824 }
825 var langMenu by remember { mutableStateOf(false) }
826 Box {
827 TextButton(onClick = { langMenu = true }) {
828 Text(
829 langOptions.firstOrNull { it.first == languageOverride }?.let { stringResource(it.second) }
830 ?: stringResource(R.string.settings_language_system),
831 )
832 }
833 DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
834 langOptions.forEach { (code, labelRes) ->
835 DropdownMenuItem(
836 text = { Text(stringResource(labelRes)) },
837 onClick = {
838 langMenu = false
839 langScope.launch {
840 container.settingsStore.setLanguageOverride(code)
841 }
842 },
843 )
844 }
845 }
846 }
847 }
848 }
849 }
851 if (showMotion) {
852 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
853 }
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) {
859 Text(
860 notificationsHeader,
861 style = MaterialTheme.typography.titleSmall,
862 color = MaterialTheme.colorScheme.primary,
863 modifier = Modifier.semantics { heading() },
864 )
865 if (rowVisible(
866 settingsQuery,
867 notificationsHeaderMatches,
868 notificationsAnyRowMatches,
869 notifRunCompleteLabel, notifRunCompleteDesc,
870 )
871 ) {
872 val notifScope = rememberCoroutineScope()
873 Row(
874 modifier = Modifier
875 .fillMaxWidth()
876 .defaultMinSize(minHeight = 48.dp)
877 .clickable(role = Role.Switch) {
878 notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
879 }
880 .padding(vertical = 8.dp),
881 verticalAlignment = Alignment.CenterVertically,
882 ) {
883 Column(modifier = Modifier.weight(1f)) {
884 Text(notifRunCompleteLabel)
885 Text(
886 notifRunCompleteDesc,
887 style = MaterialTheme.typography.bodySmall,
888 color = MaterialTheme.colorScheme.onSurfaceVariant,
889 )
890 }
891 Switch(checked = notifRunComplete, onCheckedChange = null)
892 }
893 }
894 if (rowVisible(
895 settingsQuery,
896 notificationsHeaderMatches,
897 notificationsAnyRowMatches,
898 notifPermissionLabel, notifPermissionDesc,
899 )
900 ) {
901 val notifScope = rememberCoroutineScope()
902 Row(
903 modifier = Modifier
904 .fillMaxWidth()
905 .defaultMinSize(minHeight = 48.dp)
906 .clickable(role = Role.Switch) {
907 notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
908 }
909 .padding(vertical = 8.dp),
910 verticalAlignment = Alignment.CenterVertically,
911 ) {
912 Column(modifier = Modifier.weight(1f)) {
913 Text(notifPermissionLabel)
914 Text(
915 notifPermissionDesc,
916 style = MaterialTheme.typography.bodySmall,
917 color = MaterialTheme.colorScheme.onSurfaceVariant,
918 )
919 }
920 Switch(checked = notifPermission, onCheckedChange = null)
921 }
922 }
923 if (rowVisible(
924 settingsQuery,
925 notificationsHeaderMatches,
926 notificationsAnyRowMatches,
927 notifErrorLabel, notifErrorDesc,
928 )
929 ) {
930 val notifScope = rememberCoroutineScope()
931 Row(
932 modifier = Modifier
933 .fillMaxWidth()
934 .defaultMinSize(minHeight = 48.dp)
935 .clickable(role = Role.Switch) {
936 notifScope.launch { container.settingsStore.setNotifError(!notifError) }
937 }
938 .padding(vertical = 8.dp),
939 verticalAlignment = Alignment.CenterVertically,
940 ) {
941 Column(modifier = Modifier.weight(1f)) {
942 Text(notifErrorLabel)
943 Text(
944 notifErrorDesc,
945 style = MaterialTheme.typography.bodySmall,
946 color = MaterialTheme.colorScheme.onSurfaceVariant,
947 )
948 }
949 Switch(checked = notifError, onCheckedChange = null)
950 }
951 }
952 }
954 if (showNotifications) {
955 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
956 }
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) {
962 Text(
963 swipeActionsHeader,
964 style = MaterialTheme.typography.titleSmall,
965 color = MaterialTheme.colorScheme.primary,
966 modifier = Modifier.semantics { heading() },
967 )
968 val swipeScope = rememberCoroutineScope()
969 val swipeOptions = remember {
970 listOf(
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,
975 )
976 }
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)
981 }
982 val swipeRightParsed = remember(swipeRightAction) {
983 runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
984 }
985 if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeLeftLabel)) {
986 Row(
987 modifier = Modifier
988 .fillMaxWidth()
989 .defaultMinSize(minHeight = 48.dp)
990 .padding(vertical = 8.dp),
991 verticalAlignment = Alignment.CenterVertically,
992 ) {
993 Text(swipeLeftLabel, modifier = Modifier.weight(1f))
994 Box {
995 TextButton(onClick = { swipeLeftMenu = true }) {
996 Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
997 }
998 DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
999 swipeOptions.forEach { (action, label) ->
1000 DropdownMenuItem(
1001 text = { Text(stringResource(label)) },
1002 onClick = {
1003 swipeLeftMenu = false
1004 swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
1005 },
1012 if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeRightLabel)) {
1013 Row(
1014 modifier = Modifier
1015 .fillMaxWidth()
1016 .defaultMinSize(minHeight = 48.dp)
1017 .padding(vertical = 8.dp),
1018 verticalAlignment = Alignment.CenterVertically,
1019 ) {
1020 Text(swipeRightLabel, modifier = Modifier.weight(1f))
1021 Box {
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) ->
1027 DropdownMenuItem(
1028 text = { Text(stringResource(label)) },
1029 onClick = {
1030 swipeRightMenu = false
1031 swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
1032 },
1041 if (showSwipeActions) {
1042 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1045 if (showConnection) {
1046 Text(
1047 connectionHeader,
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))
1056 Text(
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
1075 stateText?.let {
1076 Row(
1077 modifier = Modifier.padding(top = 4.dp),
1078 verticalAlignment = Alignment.CenterVertically,
1079 ) {
1080 val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
1081 connectionState == EventStreamClient.ConnectionState.AuthFailed
1082 Text(
1083 it,
1084 style = MaterialTheme.typography.bodySmall,
1085 color = if (isHardFailure)
1086 MaterialTheme.colorScheme.error
1087 else MaterialTheme.colorScheme.onSurfaceVariant,
1089 if (isHardFailure) {
1090 TextButton(
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)) }
1098 } else {
1099 Text(
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)) {
1106 Row(
1107 modifier = Modifier
1108 .fillMaxWidth()
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,
1114 ) {
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)) {
1121 NavRow(
1122 icon = Icons.Filled.QueryStats,
1123 label = usageLabel,
1124 enabled = activeProfile != null,
1125 onClick = onOpenUsage,
1128 if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, mcpLabel)) {
1129 NavRow(
1130 icon = Icons.Filled.Hub,
1131 label = mcpLabel,
1132 enabled = activeProfile != null,
1133 onClick = onOpenMcp,
1136 if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, notifSettingsLabel)) {
1137 NavRow(
1138 icon = Icons.Filled.Notifications,
1139 label = notifSettingsLabel,
1140 enabled = true,
1141 onClick = {
1142 scope.launch {
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)
1147 }.isSuccess
1148 if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
1150 },
1155 if (showConnection) {
1156 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1159 if (showBackup) {
1160 Text(
1161 backupHeader,
1162 style = MaterialTheme.typography.titleSmall,
1163 color = MaterialTheme.colorScheme.primary,
1164 modifier = Modifier.semantics { heading() },
1166 if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, backupDescText)) {
1167 Text(
1168 backupDescText,
1169 style = MaterialTheme.typography.bodySmall,
1170 color = MaterialTheme.colorScheme.onSurfaceVariant,
1171 modifier = Modifier.padding(top = 4.dp),
1174 if (rowVisible(
1175 settingsQuery,
1176 backupHeaderMatches,
1177 backupAnyRowMatches,
1178 backupIncludePasswordsLabel, backupIncludePasswordsDesc,
1180 ) {
1181 Row(
1182 modifier = Modifier
1183 .fillMaxWidth()
1184 .defaultMinSize(minHeight = 48.dp)
1185 .toggleable(
1186 value = includePasswords,
1187 onValueChange = { includePasswords = it },
1188 role = Role.Switch,
1190 .padding(vertical = 8.dp),
1191 verticalAlignment = Alignment.CenterVertically,
1192 ) {
1193 Column(modifier = Modifier.weight(1f)) {
1194 Text(backupIncludePasswordsLabel, style = MaterialTheme.typography.bodyLarge)
1195 Text(
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)) {
1205 NavRow(
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.
1211 onClick = {
1212 if (includePasswords) showExportPasswordWarning = true
1213 else runCatching { exportLauncher.launch(backupFilename()) }
1214 },
1217 if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, importBackupLabel)) {
1218 NavRow(
1219 icon = Icons.Filled.Download,
1220 label = importBackupLabel,
1221 enabled = !backupBusy,
1222 onClick = {
1223 runCatching {
1224 importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
1226 },
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.
1232 if (backupBusy) {
1233 LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
1237 if (showBackup) {
1238 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1241 if (showAbout) {
1242 Text(
1243 aboutHeader,
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).
1250 Text(
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)) {
1256 Text(
1257 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))
1266 Row(
1267 modifier = Modifier
1268 .fillMaxWidth()
1269 .defaultMinSize(minHeight = 48.dp)
1270 .clickable(role = Role.Button) { onOpenDiagnostics() }
1271 .padding(vertical = 8.dp),
1272 verticalAlignment = Alignment.CenterVertically,
1273 ) {
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) {
1281 BadgedBox(
1282 badge = {
1283 Badge {
1284 val overflow = stringResource(R.string.crash_count_overflow)
1285 Text(if (crashCount > 99) overflow else crashCount.toString())
1287 },
1288 ) {}
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)) {
1298 NavRow(
1299 icon = Icons.Filled.Code,
1300 label = sourceCodeLabel,
1301 enabled = true,
1302 onClick = {
1303 runCatching {
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) } }
1308 },
1311 if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, reportIssueLabel)) {
1312 NavRow(
1313 icon = Icons.Filled.Feedback,
1314 label = reportIssueLabel,
1315 enabled = true,
1316 onClick = {
1317 runCatching {
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) } }
1322 },
1329 // Destructive-import confirmation: applies the staged file only on Replace.
1330 pendingImportUri?.let { uri ->
1331 AlertDialog(
1332 onDismissRequest = { pendingImportUri = null },
1333 title = { Text(stringResource(R.string.import_confirm_title)) },
1334 text = { Text(stringResource(R.string.import_confirm_text)) },
1335 confirmButton = {
1336 TextButton(onClick = {
1337 pendingImportUri = null
1338 scope.launch {
1339 backupBusy = true
1340 try {
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)
1345 }.isSuccess
1346 if (ok) {
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) {
1364 intent.addFlags(
1365 android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
1366 android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK,
1368 context.startActivity(intent)
1369 } else {
1370 android.os.Process.killProcess(android.os.Process.myPid())
1373 } else {
1374 snackbar.showSnackbar(importFailedMsg)
1376 } finally {
1377 backupBusy = false
1380 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
1381 },
1382 dismissButton = {
1383 TextButton(onClick = { pendingImportUri = null }) { Text(stringResource(R.string.cancel)) }
1384 },
1388 // Plaintext-password export warning: only launches the file picker on confirm.
1389 if (showExportPasswordWarning) {
1390 AlertDialog(
1391 onDismissRequest = { showExportPasswordWarning = false },
1392 title = { Text(stringResource(R.string.export_passwords_title)) },
1393 text = { Text(stringResource(R.string.export_passwords_warning)) },
1394 confirmButton = {
1395 TextButton(onClick = {
1396 showExportPasswordWarning = false
1397 runCatching { exportLauncher.launch(backupFilename()) }
1398 }) { Text(stringResource(R.string.export_backup), color = MaterialTheme.colorScheme.error) }
1399 },
1400 dismissButton = {
1401 TextButton(onClick = { showExportPasswordWarning = false }) { Text(stringResource(R.string.cancel)) }
1402 },
1407/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
1408@Composable
1409private fun NavRow(
1410 icon: androidx.compose.ui.graphics.vector.ImageVector,
1411 label: String,
1412 enabled: Boolean,
1413 onClick: () -> Unit,
1414) {
1415 Row(
1416 modifier = Modifier
1417 .fillMaxWidth()
1418 .defaultMinSize(minHeight = 48.dp)
1419 .clickable(enabled = enabled, role = Role.Button) { onClick() }
1420 .padding(vertical = 8.dp),
1421 verticalAlignment = Alignment.CenterVertically,
1422 ) {
1423 Icon(
1424 icon,
1425 contentDescription = null,
1426 tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
1428 Text(
1429 label,
1430 modifier = Modifier.weight(1f).padding(start = 8.dp),
1431 color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
1433 Icon(
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. */
1444@Composable
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
1450 Text(
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),
1455 Text(
1456 stringResource(R.string.chat_text_size_desc),
1457 style = MaterialTheme.typography.bodySmall,
1458 color = MaterialTheme.colorScheme.onSurfaceVariant,
1460 Row(verticalAlignment = Alignment.CenterVertically) {
1461 Slider(
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),
1468 Text(
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),
1475 TextButton(
1476 onClick = {
1477 sliderValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE
1478 onScaleChange(SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
1479 },
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) {
1490 Text(
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),
1500@Composable
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
1514 Row(
1515 modifier = Modifier
1516 .fillMaxWidth()
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,
1522 ) {
1523 RadioButton(selected = selected, onClick = null)
1524 Text(
1525 when (mode) {
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)
1530 },
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.
1537 Text(
1538 stringResource(R.string.theme_using_system_colors),
1539 style = MaterialTheme.typography.labelSmall,
1540 color = MaterialTheme.colorScheme.onSurfaceVariant,
1541 textAlign = TextAlign.End,
1543 } else {
1544 // Three small color dots previewing the palette for this mode.
1545 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
1546 swatches.forEach { color ->
1547 Box(
1548 modifier = Modifier
1549 .size(14.dp)
1550 .clip(CircleShape)
1551 .background(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.
1569@Composable
1570private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
1572/**
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.
1576 */
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) }
1584/**
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.
1588 */
1589private fun rowVisible(
1590 query: String,
1591 headerMatches: Boolean,
1592 anyRowMatches: Boolean,
1593 vararg rowTexts: String,
1594): Boolean {
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)
1613@Composable
1614private fun AppLockReLockSelector(seconds: Int, onSelect: (Int) -> Unit) {
1615 var expanded by remember { mutableStateOf(false) }
1616 ExposedDropdownMenuBox(
1617 expanded = expanded,
1618 onExpandedChange = { expanded = it },
1619 modifier = Modifier
1620 .fillMaxWidth()
1621 .padding(start = 56.dp, top = 4.dp),
1622 ) {
1623 OutlinedTextField(
1624 value = relockLabel(seconds),
1625 onValueChange = {},
1626 readOnly = true,
1627 singleLine = true,
1628 label = { Text(stringResource(R.string.app_lock_relock)) },
1629 trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
1630 modifier = Modifier
1631 .menuAnchor(MenuAnchorType.PrimaryNotEditable, true)
1632 .fillMaxWidth(),
1634 ExposedDropdownMenu(
1635 expanded = expanded,
1636 onDismissRequest = { expanded = false },
1637 ) {
1638 SettingsStore.APP_LOCK_RELOCK_OPTIONS_SECONDS.forEach { option ->
1639 DropdownMenuItem(
1640 text = { Text(relockLabel(option)) },
1641 onClick = { expanded = false; onSelect(option) },
1648@Composable
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)
1652 seconds < 3600 -> {
1653 val mins = seconds / 60
1654 pluralStringResource(R.plurals.app_lock_relock_minutes, mins, mins)
1656 else -> {
1657 val hours = seconds / 3600
1658 pluralStringResource(R.plurals.app_lock_relock_hours, hours, hours)