@@ -22,12 +22,15 @@ import androidx.compose.foundation.layout.wrapContentWidth
22import androidx.compose.foundation.rememberScrollState
22import androidx.compose.foundation.rememberScrollState
23import androidx.compose.foundation.selection.selectable
23import androidx.compose.foundation.selection.selectable
24import androidx.compose.foundation.selection.toggleable
24import androidx.compose.foundation.selection.toggleable
25+import androidx.compose.foundation.text.KeyboardActions
26+import androidx.compose.foundation.text.KeyboardOptions
25import androidx.compose.foundation.verticalScroll
27import androidx.compose.foundation.verticalScroll
26import androidx.compose.foundation.shape.CircleShape
28import androidx.compose.foundation.shape.CircleShape
27import androidx.compose.material.icons.Icons
29import androidx.compose.material.icons.Icons
28import androidx.compose.material.icons.automirrored.filled.ArrowBack
30import androidx.compose.material.icons.automirrored.filled.ArrowBack
29import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
31import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
30import androidx.compose.material.icons.filled.BugReport
32import androidx.compose.material.icons.filled.BugReport
33+import androidx.compose.material.icons.filled.Clear
31import androidx.compose.material.icons.filled.Code
34import androidx.compose.material.icons.filled.Code
32import androidx.compose.material.icons.filled.Dns
35import androidx.compose.material.icons.filled.Dns
33import androidx.compose.material.icons.filled.Download
36import androidx.compose.material.icons.filled.Download
@@ -35,6 +38,7 @@ import androidx.compose.material.icons.filled.Feedback
35import androidx.compose.material.icons.filled.Hub
38import androidx.compose.material.icons.filled.Hub
36import androidx.compose.material.icons.filled.Notifications
39import androidx.compose.material.icons.filled.Notifications
37import androidx.compose.material.icons.filled.QueryStats
40import androidx.compose.material.icons.filled.QueryStats
41+import androidx.compose.material.icons.filled.Search
38import androidx.compose.material.icons.filled.Upload
42import androidx.compose.material.icons.filled.Upload
39import androidx.compose.material3.AlertDialog
43import androidx.compose.material3.AlertDialog
40import androidx.compose.material3.ExperimentalMaterial3Api
44import androidx.compose.material3.ExperimentalMaterial3Api
@@ -78,12 +82,15 @@ import androidx.compose.ui.graphics.Color
78import androidx.compose.ui.input.nestedscroll.nestedScroll
82import androidx.compose.ui.input.nestedscroll.nestedScroll
79import androidx.compose.ui.platform.LocalContext
83import androidx.compose.ui.platform.LocalContext
80import androidx.compose.ui.platform.LocalDensity
84import androidx.compose.ui.platform.LocalDensity
85+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
86+import androidx.compose.ui.platform.testTag
81import androidx.compose.ui.res.pluralStringResource
87import androidx.compose.ui.res.pluralStringResource
82import androidx.compose.ui.res.stringResource
88import androidx.compose.ui.res.stringResource
83import androidx.compose.ui.semantics.Role
89import androidx.compose.ui.semantics.Role
84import androidx.compose.ui.semantics.heading
90import androidx.compose.ui.semantics.heading
85import androidx.compose.ui.semantics.role
91import androidx.compose.ui.semantics.role
86import androidx.compose.ui.semantics.semantics
92import androidx.compose.ui.semantics.semantics
93+import androidx.compose.ui.text.input.ImeAction
87import androidx.compose.ui.text.style.TextAlign
94import androidx.compose.ui.text.style.TextAlign
88import androidx.compose.ui.unit.dp
95import androidx.compose.ui.unit.dp
89import androidx.lifecycle.compose.LocalLifecycleOwner
96import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -202,6 +209,9 @@ fun SettingsScreen(
202 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
209 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
203 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
210 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
204 var includePasswords by rememberSaveable { mutableStateOf(false) }
211 var includePasswords by rememberSaveable { mutableStateOf(false) }
212+ // Settings search/filter query. rememberSaveable so the filter survives config changes
213+ // (rotation, theme switch) — the same reason the backup toggle below is saved.
214+ var settingsQuery by rememberSaveable { mutableStateOf("") }
205 // True while an export or import is in flight. Used to disable both NavRow triggers so a
215 // True while an export or import is in flight. Used to disable both NavRow triggers so a
206 // double-tap (or a slow device) can't launch a second file picker or start a second
216 // double-tap (or a slow device) can't launch a second file picker or start a second
207 // operation while the first is still reading/writing. Mirrors the busy-flag pattern used
217 // operation while the first is still reading/writing. Mirrors the busy-flag pattern used
@@ -283,698 +293,1003 @@ fun SettingsScreen(
283 .padding(16.dp),
293 .padding(16.dp),
285 val s = settings
295 val s = settings
286− Text(
296+ // Resolve section-header strings once so the filter logic below can match
297+ // against them without re-resolving per recomposition. These mirror the
298+ // Text() headers rendered further down.
287− stringResource(R.string.appearance),
299+ val appearanceHeader = stringResource(R.string.appearance)
288− style = MaterialTheme.typography.titleSmall,
300+ val textDisplayHeader = stringResource(R.string.text_display)
289− color = MaterialTheme.colorScheme.primary,
301+ val motionHeader = stringResource(R.string.settings_motion)
290− modifier = Modifier.semantics { heading() },
302+ val notificationsHeader = stringResource(R.string.settings_notifications)
303+ val swipeActionsHeader = stringResource(R.string.settings_swipe_actions)
304+ val connectionHeader = stringResource(R.string.connection)
305+ val backupHeader = stringResource(R.string.backup_restore)
306+ val aboutHeader = stringResource(R.string.about)
307+ // Row label/description strings used for filtering. Kept in the same order as the
308+ // rows appear, so a future editor can line them up with the rows below.
309+ val dynamicColorLabel = stringResource(R.string.dynamic_color)
310+ val dynamicColorDesc = stringResource(R.string.dynamic_color_desc)
311+ val sendOnEnterLabel = stringResource(R.string.send_on_enter)
312+ val sendOnEnterDesc = stringResource(R.string.send_on_enter_desc)
313+ val appLockLabel = stringResource(R.string.app_lock)
314+ val appLockDesc = stringResource(R.string.app_lock_desc)
315+ val wrapCodeLabel = stringResource(R.string.wrap_code_blocks)
316+ val wrapCodeDesc = stringResource(R.string.wrap_code_blocks_desc)
317+ val compactSpacingLabel = stringResource(R.string.compact_message_spacing)
318+ val compactSpacingDesc = stringResource(R.string.compact_message_spacing_desc)
319+ val hapticsLabel = stringResource(R.string.settings_haptics)
320+ val hapticsDesc = stringResource(R.string.settings_haptics_desc)
321+ val reducedMotionLabel = stringResource(R.string.settings_reduced_motion)
322+ val reducedMotionDesc = stringResource(R.string.settings_reduced_motion_desc)
323+ val languageLabel = stringResource(R.string.settings_language)
324+ val languageDesc = stringResource(R.string.settings_language_desc)
325+ val notifRunCompleteLabel = stringResource(R.string.settings_notif_run_complete)
326+ val notifRunCompleteDesc = stringResource(R.string.settings_notif_run_complete_desc)
327+ val notifPermissionLabel = stringResource(R.string.settings_notif_permission)
328+ val notifPermissionDesc = stringResource(R.string.settings_notif_permission_desc)
329+ val notifErrorLabel = stringResource(R.string.settings_notif_error)
330+ val notifErrorDesc = stringResource(R.string.settings_notif_error_desc)
331+ val swipeLeftLabel = stringResource(R.string.settings_swipe_left)
332+ val swipeRightLabel = stringResource(R.string.settings_swipe_right)
333+ val manageServersLabel = stringResource(R.string.manage_servers)
334+ val usageLabel = stringResource(R.string.usage_title)
335+ val mcpLabel = stringResource(R.string.mcp_servers)
336+ val notifSettingsLabel = stringResource(R.string.notification_settings)
337+ val backupIncludePasswordsLabel = stringResource(R.string.backup_include_passwords)
338+ val backupIncludePasswordsDesc = stringResource(R.string.backup_include_passwords_desc)
339+ val exportBackupLabel = stringResource(R.string.export_backup)
340+ val importBackupLabel = stringResource(R.string.import_backup)
341+ val diagnosticsLabel = stringResource(R.string.diagnostics)
342+ val sourceCodeLabel = stringResource(R.string.source_code)
343+ val reportIssueLabel = stringResource(R.string.report_issue)
344+ val chatTextSizeDesc = stringResource(R.string.chat_text_size_desc)
345+ val backupDescText = stringResource(R.string.backup_desc)
346+ val aboutDescText = stringResource(R.string.about_desc)
348+ // Per-section filter state. For each section we compute:
349+ // - headerMatches: the section header itself matches the query
350+ // - anyRowMatches: at least one row in the section matches
351+ // A section is shown when either is true (or when the query is empty).
352+ // A row is shown when the header matches alone (browse the whole matched section),
353+ // or when the row itself matches.
354+ val appearanceHeaderMatches = matchesQuery(settingsQuery, appearanceHeader)
355+ val appearanceAnyRowMatches = matchesQuery(
357+ dynamicColorLabel, dynamicColorDesc,
358+ sendOnEnterLabel, sendOnEnterDesc,
359+ appLockLabel, appLockDesc,
292− if (s != null) {
361+ val showAppearance = appearanceHeaderMatches || appearanceAnyRowMatches
293− ThemeMode.entries.forEach { mode ->
362+ val textDisplayHeaderMatches = matchesQuery(settingsQuery, textDisplayHeader)
294− ThemeRow(
363+ val textDisplayAnyRowMatches = matchesQuery(
295− mode = mode,
364+ settingsQuery,
296− selected = s.themeMode == mode,
365+ wrapCodeLabel, wrapCodeDesc,
297− // When Material You is active the static Tokyo Night swatches would be a
366+ compactSpacingLabel, compactSpacingDesc,
298− // lie, so ThemeRow hides them and notes that system colors are in use.
367+ chatTextSizeDesc,
299− usingSystemColors = s.dynamicColor && dynamicColorAvailable,
368+ )
300− onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
369+ val showTextDisplay = textDisplayHeaderMatches || textDisplayAnyRowMatches
370+ val motionHeaderMatches = matchesQuery(settingsQuery, motionHeader)
371+ val motionAnyRowMatches = matchesQuery(
373+ hapticsLabel, hapticsDesc,
374+ reducedMotionLabel, reducedMotionDesc,
375+ languageLabel, languageDesc,
377+ val showMotion = motionHeaderMatches || motionAnyRowMatches
378+ val notificationsHeaderMatches = matchesQuery(settingsQuery, notificationsHeader)
379+ val notificationsAnyRowMatches = matchesQuery(
381+ notifRunCompleteLabel, notifRunCompleteDesc,
382+ notifPermissionLabel, notifPermissionDesc,
383+ notifErrorLabel, notifErrorDesc,
385+ val showNotifications = notificationsHeaderMatches || notificationsAnyRowMatches
386+ val swipeHeaderMatches = matchesQuery(settingsQuery, swipeActionsHeader)
387+ val swipeAnyRowMatches = matchesQuery(
392+ val showSwipeActions = swipeHeaderMatches || swipeAnyRowMatches
393+ val connectionHeaderMatches = matchesQuery(settingsQuery, connectionHeader)
394+ val connectionAnyRowMatches = matchesQuery(
401+ val showConnection = connectionHeaderMatches || connectionAnyRowMatches
402+ val backupHeaderMatches = matchesQuery(settingsQuery, backupHeader)
403+ val backupAnyRowMatches = matchesQuery(
406+ backupIncludePasswordsLabel, backupIncludePasswordsDesc,
410+ val showBackup = backupHeaderMatches || backupAnyRowMatches
411+ val aboutHeaderMatches = matchesQuery(settingsQuery, aboutHeader)
412+ val aboutAnyRowMatches = matchesQuery(
302− }
419+ val showAbout = aboutHeaderMatches || aboutAnyRowMatches
421+ val keyboardController = LocalSoftwareKeyboardController.current
423+ value = settingsQuery,
424+ onValueChange = { settingsQuery = it },
304− Spacer(Modifier.size(8.dp))
425+ modifier = Modifier
305− // Dynamic color (Material You) only works on Android 12+, so hide the toggle
426+ .fillMaxWidth()
306− // on older devices instead of offering a control that silently does nothing.
427+ .padding(bottom = 8.dp)
428+ .testTag("settings_search"),
429+ label = { Text(stringResource(R.string.search_settings)) },
430+ placeholder = { Text(stringResource(R.string.search_settings)) },
431+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
432+ trailingIcon = if (settingsQuery.isNotEmpty()) {
434+ IconButton(onClick = { settingsQuery = "" }) {
435+ Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.clear_search))
440+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
441+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
444+ if (showAppearance) {
447+ style = MaterialTheme.typography.titleSmall,
448+ color = MaterialTheme.colorScheme.primary,
449+ modifier = Modifier.semantics { heading() },
452+ ThemeMode.entries.forEach { mode ->
453+ // Theme rows have no description text; match against the mode label only.
454+ // ThemeRow renders the label internally via stringResource, so resolve it
455+ // here for the filter check.
456+ val modeLabel = when (mode) {
457+ ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
458+ ThemeMode.LIGHT -> stringResource(R.string.theme_light)
459+ ThemeMode.DARK -> stringResource(R.string.theme_dark)
460+ ThemeMode.AMOLED -> stringResource(R.string.theme_amoled)
462+ if (rowVisible(settingsQuery, appearanceHeaderMatches, appearanceAnyRowMatches, modeLabel)) {
465+ selected = s.themeMode == mode,
466+ // When Material You is active the static Tokyo Night swatches would be a
467+ // lie, so ThemeRow hides them and notes that system colors are in use.
307− if (dynamicColorAvailable) {
468+ usingSystemColors = s.dynamicColor && dynamicColorAvailable,
308− val amoledActive = s.themeMode == ThemeMode.AMOLED
312− .defaultMinSize(minHeight = 48.dp)
314− value = s.dynamicColor,
315− enabled = !amoledActive,
316− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
469+ onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
319− .padding(vertical = 8.dp),
471+ }
320− verticalAlignment = Alignment.CenterVertically,
472+ }
474+ if (dynamicColorAvailable && rowVisible(
476+ appearanceHeaderMatches,
477+ appearanceAnyRowMatches,
478+ dynamicColorLabel, dynamicColorDesc,
481+ Spacer(Modifier.size(8.dp))
482+ // Dynamic color (Material You) only works on Android 12+, so hide the toggle
483+ // on older devices instead of offering a control that silently does nothing.
484+ val amoledActive = s.themeMode == ThemeMode.AMOLED
488+ .defaultMinSize(minHeight = 48.dp)
490+ value = s.dynamicColor,
491+ enabled = !amoledActive,
492+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
495+ .padding(vertical = 8.dp),
496+ verticalAlignment = Alignment.CenterVertically,
322− Column(modifier = Modifier.weight(1f)) {
498+ Column(modifier = Modifier.weight(1f)) {
323− Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
499+ Text(dynamicColorLabel, style = MaterialTheme.typography.bodyLarge)
325− stringResource(R.string.dynamic_color_desc),
326− style = MaterialTheme.typography.bodySmall,
327− color = MaterialTheme.colorScheme.onSurfaceVariant,
331− stringResource(R.string.dynamic_color_disabled_amoled),
501+ dynamicColorDesc,
332 style = MaterialTheme.typography.bodySmall,
502 style = MaterialTheme.typography.bodySmall,
333 color = MaterialTheme.colorScheme.onSurfaceVariant,
503 color = MaterialTheme.colorScheme.onSurfaceVariant,
507+ stringResource(R.string.dynamic_color_disabled_amoled),
508+ style = MaterialTheme.typography.bodySmall,
509+ color = MaterialTheme.colorScheme.onSurfaceVariant,
514+ checked = s.dynamicColor,
515+ onCheckedChange = null,
516+ enabled = !amoledActive,
338− checked = s.dynamicColor,
339− onCheckedChange = null,
340− enabled = !amoledActive,
345− // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
521+ // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
346− Spacer(Modifier.size(4.dp))
522+ if (rowVisible(
347− Row(
523+ settingsQuery,
348− modifier = Modifier
524+ appearanceHeaderMatches,
349− .fillMaxWidth()
525+ appearanceAnyRowMatches,
350− .defaultMinSize(minHeight = 48.dp)
526+ sendOnEnterLabel, sendOnEnterDesc,
352− value = s.sendOnEnter,
353− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
356− .padding(vertical = 8.dp),
357− verticalAlignment = Alignment.CenterVertically,
359− Column(modifier = Modifier.weight(1f)) {
360− Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
362− stringResource(R.string.send_on_enter_desc),
363− style = MaterialTheme.typography.bodySmall,
364− color = MaterialTheme.colorScheme.onSurfaceVariant,
529+ Spacer(Modifier.size(4.dp))
533+ .defaultMinSize(minHeight = 48.dp)
535+ value = s.sendOnEnter,
536+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
539+ .padding(vertical = 8.dp),
540+ verticalAlignment = Alignment.CenterVertically,
542+ Column(modifier = Modifier.weight(1f)) {
543+ Text(sendOnEnterLabel, style = MaterialTheme.typography.bodyLarge)
546+ style = MaterialTheme.typography.bodySmall,
547+ color = MaterialTheme.colorScheme.onSurfaceVariant,
551+ checked = s.sendOnEnter,
552+ onCheckedChange = null,
368− checked = s.sendOnEnter,
369− onCheckedChange = null,
373− // App lock: gate on device capability. Always allow turning it *off* (in case
557+ // App lock: gate on device capability. Always allow turning it *off* (in case
374− // the enrolled biometric was later removed), but only allow turning it on when
558+ // the enrolled biometric was later removed), but only allow turning it on when
375− // a biometric or device credential is actually available.
559+ // a biometric or device credential is actually available.
376− val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
560+ if (rowVisible(
377− val appLockToggleable = appLockAvailable || s.appLock
561+ settingsQuery,
378− Spacer(Modifier.size(4.dp))
562+ appearanceHeaderMatches,
379− Row(
563+ appearanceAnyRowMatches,
380− modifier = Modifier
564+ appLockLabel, appLockDesc,
382− .defaultMinSize(minHeight = 48.dp)
385− enabled = appLockToggleable,
386− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
389− .padding(vertical = 8.dp),
390− verticalAlignment = Alignment.CenterVertically,
392− Column(modifier = Modifier.weight(1f)) {
393− Text(stringResource(R.string.app_lock), style = MaterialTheme.typography.bodyLarge)
395− stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
396− style = MaterialTheme.typography.bodySmall,
397− color = MaterialTheme.colorScheme.onSurfaceVariant,
567+ val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
568+ val appLockToggleable = appLockAvailable || s.appLock
569+ Spacer(Modifier.size(4.dp))
573+ .defaultMinSize(minHeight = 48.dp)
576+ enabled = appLockToggleable,
577+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
580+ .padding(vertical = 8.dp),
581+ verticalAlignment = Alignment.CenterVertically,
583+ Column(modifier = Modifier.weight(1f)) {
584+ Text(appLockLabel, style = MaterialTheme.typography.bodyLarge)
586+ stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
587+ style = MaterialTheme.typography.bodySmall,
588+ color = MaterialTheme.colorScheme.onSurfaceVariant,
592+ checked = s.appLock,
593+ onCheckedChange = null,
594+ enabled = appLockToggleable,
598+ AppLockReLockSelector(
599+ seconds = appLockReLockSeconds,
601+ scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
608+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
609+ contentAlignment = Alignment.Center,
611+ CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
401− checked = s.appLock,
402− onCheckedChange = null,
403− enabled = appLockToggleable,
407− AppLockReLockSelector(
408− seconds = appLockReLockSeconds,
410− scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
416− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
417− contentAlignment = Alignment.Center,
419− CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
616+ if (showAppearance) {
423− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
617+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
620+ if (showTextDisplay) {
426− stringResource(R.string.text_display),
622+ textDisplayHeader,
427− style = MaterialTheme.typography.titleSmall,
623+ style = MaterialTheme.typography.titleSmall,
428− color = MaterialTheme.colorScheme.primary,
624+ color = MaterialTheme.colorScheme.primary,
429− modifier = Modifier.semantics { heading() },
625+ modifier = Modifier.semantics { heading() },
627+ if (rowVisible(settingsQuery, textDisplayHeaderMatches, textDisplayAnyRowMatches, chatTextSizeDesc)) {
431− ChatTextSizeControl(
628+ ChatTextSizeControl(
432− scale = chatTextScale,
629+ scale = chatTextScale,
433− onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
630+ onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
435− Spacer(Modifier.size(4.dp))
439− .defaultMinSize(minHeight = 48.dp)
442− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
445− .padding(vertical = 8.dp),
632+ }
446− verticalAlignment = Alignment.CenterVertically,
633+ if (rowVisible(
447− ) {
634+ settingsQuery,
448− Column(modifier = Modifier.weight(1f)) {
635+ textDisplayHeaderMatches,
449− Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
636+ textDisplayAnyRowMatches,
450− Text(
637+ wrapCodeLabel, wrapCodeDesc,
451− stringResource(R.string.wrap_code_blocks_desc),
452− style = MaterialTheme.typography.bodySmall,
453− color = MaterialTheme.colorScheme.onSurfaceVariant,
640+ Spacer(Modifier.size(4.dp))
644+ .defaultMinSize(minHeight = 48.dp)
647+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
650+ .padding(vertical = 8.dp),
651+ verticalAlignment = Alignment.CenterVertically,
653+ Column(modifier = Modifier.weight(1f)) {
654+ Text(wrapCodeLabel, style = MaterialTheme.typography.bodyLarge)
657+ style = MaterialTheme.typography.bodySmall,
658+ color = MaterialTheme.colorScheme.onSurfaceVariant,
661+ Switch(checked = codeWrap, onCheckedChange = null)
456− Switch(checked = codeWrap, onCheckedChange = null)
459− // Compact message spacing: a power-user toggle to fit more conversation on screen.
665+ // Compact message spacing: a power-user toggle to fit more conversation on screen.
460− Spacer(Modifier.size(4.dp))
666+ if (rowVisible(
461− Row(
667+ settingsQuery,
462− modifier = Modifier
668+ textDisplayHeaderMatches,
463− .fillMaxWidth()
669+ textDisplayAnyRowMatches,
464− .defaultMinSize(minHeight = 48.dp)
670+ compactSpacingLabel, compactSpacingDesc,
466− value = compactSpacing,
467− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
470− .padding(vertical = 8.dp),
471− verticalAlignment = Alignment.CenterVertically,
473− Column(modifier = Modifier.weight(1f)) {
474− Text(stringResource(R.string.compact_message_spacing), style = MaterialTheme.typography.bodyLarge)
476− stringResource(R.string.compact_message_spacing_desc),
477− style = MaterialTheme.typography.bodySmall,
478− color = MaterialTheme.colorScheme.onSurfaceVariant,
673+ Spacer(Modifier.size(4.dp))
677+ .defaultMinSize(minHeight = 48.dp)
679+ value = compactSpacing,
680+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
683+ .padding(vertical = 8.dp),
684+ verticalAlignment = Alignment.CenterVertically,
686+ Column(modifier = Modifier.weight(1f)) {
687+ Text(compactSpacingLabel, style = MaterialTheme.typography.bodyLarge)
690+ style = MaterialTheme.typography.bodySmall,
691+ color = MaterialTheme.colorScheme.onSurfaceVariant,
694+ Switch(checked = compactSpacing, onCheckedChange = null)
481− Switch(checked = compactSpacing, onCheckedChange = null)
699+ if (showTextDisplay) {
484− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
700+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
486 // Motion & feedback: in-app toggles for haptics and reduced motion. The app
703 // Motion & feedback: in-app toggles for haptics and reduced motion. The app
487 // already honors the system Developer Options animator scale for reduced motion,
704 // already honors the system Developer Options animator scale for reduced motion,
488 // but this gives users an in-app control without disabling motion OS-wide.
705 // but this gives users an in-app control without disabling motion OS-wide.
489 // Haptics default on (the existing behavior); an in-app toggle lets users
706 // Haptics default on (the existing behavior); an in-app toggle lets users
490 // disable them without turning off system vibration.
707 // disable them without turning off system vibration.
491− Text(
708+ if (showMotion) {
492− stringResource(R.string.settings_motion),
709+ Text(
493− style = MaterialTheme.typography.titleSmall,
711+ style = MaterialTheme.typography.titleSmall,
494− color = MaterialTheme.colorScheme.primary,
712+ color = MaterialTheme.colorScheme.primary,
495− modifier = Modifier.semantics { heading() },
713+ modifier = Modifier.semantics { heading() },
715+ if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, hapticsLabel, hapticsDesc)) {
497− val hapticsScope = rememberCoroutineScope()
716+ val hapticsScope = rememberCoroutineScope()
499− modifier = Modifier
718+ modifier = Modifier
500− .fillMaxWidth()
719+ .fillMaxWidth()
501− .defaultMinSize(minHeight = 48.dp)
720+ .defaultMinSize(minHeight = 48.dp)
502− .clickable(role = Role.Switch) {
721+ .clickable(role = Role.Switch) {
503− hapticsScope.launch {
722+ hapticsScope.launch {
504− container.settingsStore.setHapticsEnabled(!hapticsEnabled)
723+ container.settingsStore.setHapticsEnabled(!hapticsEnabled)
726+ .padding(vertical = 8.dp),
727+ verticalAlignment = Alignment.CenterVertically,
729+ Column(modifier = Modifier.weight(1f)) {
733+ style = MaterialTheme.typography.bodySmall,
734+ color = MaterialTheme.colorScheme.onSurfaceVariant,
737+ Switch(checked = hapticsEnabled, onCheckedChange = null)
507− .padding(vertical = 8.dp),
508− verticalAlignment = Alignment.CenterVertically,
510− Column(modifier = Modifier.weight(1f)) {
511− Text(stringResource(R.string.settings_haptics))
513− stringResource(R.string.settings_haptics_desc),
514− style = MaterialTheme.typography.bodySmall,
515− color = MaterialTheme.colorScheme.onSurfaceVariant,
518− Switch(checked = hapticsEnabled, onCheckedChange = null)
740+ if (rowVisible(
519− }
741+ settingsQuery,
742+ motionHeaderMatches,
743+ motionAnyRowMatches,
744+ reducedMotionLabel, reducedMotionDesc,
520− val motionScope = rememberCoroutineScope()
747+ val motionScope = rememberCoroutineScope()
522− modifier = Modifier
749+ modifier = Modifier
523− .fillMaxWidth()
750+ .fillMaxWidth()
524− .defaultMinSize(minHeight = 48.dp)
751+ .defaultMinSize(minHeight = 48.dp)
525− .clickable(role = Role.Switch) {
752+ .clickable(role = Role.Switch) {
526− motionScope.launch {
753+ motionScope.launch {
527− container.settingsStore.setReducedMotion(!reducedMotion)
754+ container.settingsStore.setReducedMotion(!reducedMotion)
757+ .padding(vertical = 8.dp),
758+ verticalAlignment = Alignment.CenterVertically,
760+ Column(modifier = Modifier.weight(1f)) {
761+ Text(reducedMotionLabel)
764+ style = MaterialTheme.typography.bodySmall,
765+ color = MaterialTheme.colorScheme.onSurfaceVariant,
768+ Switch(checked = reducedMotion, onCheckedChange = null)
530− .padding(vertical = 8.dp),
531− verticalAlignment = Alignment.CenterVertically,
533− Column(modifier = Modifier.weight(1f)) {
534− Text(stringResource(R.string.settings_reduced_motion))
536− stringResource(R.string.settings_reduced_motion_desc),
537− style = MaterialTheme.typography.bodySmall,
538− color = MaterialTheme.colorScheme.onSurfaceVariant,
541− Switch(checked = reducedMotion, onCheckedChange = null)
543− // Language override: an in-app language picker so a user can use the app in a
771+ // Language override: an in-app language picker so a user can use the app in a
544− // different language without changing the system locale. Uses Android 13's
772+ // different language without changing the system locale. Uses Android 13's
545− // per-app language API; on older versions a restart is needed.
773+ // per-app language API; on older versions a restart is needed.
774+ if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, languageLabel, languageDesc)) {
546− val langScope = rememberCoroutineScope()
775+ val langScope = rememberCoroutineScope()
548− modifier = Modifier
777+ modifier = Modifier
549− .fillMaxWidth()
778+ .fillMaxWidth()
550− .defaultMinSize(minHeight = 48.dp)
779+ .defaultMinSize(minHeight = 48.dp)
551− .padding(vertical = 8.dp),
780+ .padding(vertical = 8.dp),
552− verticalAlignment = Alignment.CenterVertically,
781+ verticalAlignment = Alignment.CenterVertically,
554− Column(modifier = Modifier.weight(1f)) {
783+ Column(modifier = Modifier.weight(1f)) {
555− Text(stringResource(R.string.settings_language))
784+ Text(languageLabel)
557− if (languageOverride.isEmpty()) stringResource(R.string.settings_language_desc)
786+ if (languageOverride.isEmpty()) languageDesc
558− else languageOverride,
787+ else languageOverride,
559− style = MaterialTheme.typography.bodySmall,
788+ style = MaterialTheme.typography.bodySmall,
560− color = MaterialTheme.colorScheme.onSurfaceVariant,
789+ color = MaterialTheme.colorScheme.onSurfaceVariant,
563− val langOptions = remember {
564− listOf("" to R.string.settings_language_system, "en" to R.string.app_name, "es" to R.string.app_name)
566− var langMenu by remember { mutableStateOf(false) }
568− TextButton(onClick = { langMenu = true }) {
569− Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
571− DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
572− langOptions.forEach { (code, _) ->
574− text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
578− container.settingsStore.setLanguageOverride(code)
792+ val langOptions = remember {
793+ listOf("" to R.string.settings_language_system, "en" to R.string.app_name, "es" to R.string.app_name)
795+ var langMenu by remember { mutableStateOf(false) }
797+ TextButton(onClick = { langMenu = true }) {
798+ Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
800+ DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
801+ langOptions.forEach { (code, _) ->
803+ text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
807+ container.settingsStore.setLanguageOverride(code)
587− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
819+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
589 // Notifications: granular toggles for each notification type, so a user can
822 // Notifications: granular toggles for each notification type, so a user can
590 // mute specific types in-app (in addition to the OS channel controls). Each
823 // mute specific types in-app (in addition to the OS channel controls). Each
591 // toggle gates whether the app posts that notification type at all.
824 // toggle gates whether the app posts that notification type at all.
592− Text(
825+ if (showNotifications) {
593− stringResource(R.string.settings_notifications),
826+ Text(
827+ notificationsHeader,
594− style = MaterialTheme.typography.titleSmall,
828+ style = MaterialTheme.typography.titleSmall,
595− color = MaterialTheme.colorScheme.primary,
829+ color = MaterialTheme.colorScheme.primary,
596− modifier = Modifier.semantics { heading() },
830+ modifier = Modifier.semantics { heading() },
598− val notifScope = rememberCoroutineScope()
832+ if (rowVisible(
599− Row(
833+ settingsQuery,
600− modifier = Modifier
834+ notificationsHeaderMatches,
601− .fillMaxWidth()
835+ notificationsAnyRowMatches,
602− .defaultMinSize(minHeight = 48.dp)
836+ notifRunCompleteLabel, notifRunCompleteDesc,
603− .clickable(role = Role.Switch) {
604− notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
606− .padding(vertical = 8.dp),
607− verticalAlignment = Alignment.CenterVertically,
609− Column(modifier = Modifier.weight(1f)) {
610− Text(stringResource(R.string.settings_notif_run_complete))
612− stringResource(R.string.settings_notif_run_complete_desc),
613− style = MaterialTheme.typography.bodySmall,
614− color = MaterialTheme.colorScheme.onSurfaceVariant,
617− Switch(checked = notifRunComplete, onCheckedChange = null)
839+ val notifScope = rememberCoroutineScope()
620− modifier = Modifier
841+ modifier = Modifier
621− .fillMaxWidth()
842+ .fillMaxWidth()
622− .defaultMinSize(minHeight = 48.dp)
843+ .defaultMinSize(minHeight = 48.dp)
623− .clickable(role = Role.Switch) {
844+ .clickable(role = Role.Switch) {
624− notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
845+ notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
847+ .padding(vertical = 8.dp),
848+ verticalAlignment = Alignment.CenterVertically,
850+ Column(modifier = Modifier.weight(1f)) {
851+ Text(notifRunCompleteLabel)
853+ notifRunCompleteDesc,
854+ style = MaterialTheme.typography.bodySmall,
855+ color = MaterialTheme.colorScheme.onSurfaceVariant,
858+ Switch(checked = notifRunComplete, onCheckedChange = null)
626− .padding(vertical = 8.dp),
627− verticalAlignment = Alignment.CenterVertically,
629− Column(modifier = Modifier.weight(1f)) {
630− Text(stringResource(R.string.settings_notif_permission))
632− stringResource(R.string.settings_notif_permission_desc),
633− style = MaterialTheme.typography.bodySmall,
634− color = MaterialTheme.colorScheme.onSurfaceVariant,
637− Switch(checked = notifPermission, onCheckedChange = null)
861+ if (rowVisible(
638− }
862+ settingsQuery,
863+ notificationsHeaderMatches,
864+ notificationsAnyRowMatches,
865+ notifPermissionLabel, notifPermissionDesc,
868+ val notifScope = rememberCoroutineScope()
640− modifier = Modifier
870+ modifier = Modifier
641− .fillMaxWidth()
871+ .fillMaxWidth()
642− .defaultMinSize(minHeight = 48.dp)
872+ .defaultMinSize(minHeight = 48.dp)
643− .clickable(role = Role.Switch) {
873+ .clickable(role = Role.Switch) {
644− notifScope.launch { container.settingsStore.setNotifError(!notifError) }
874+ notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
876+ .padding(vertical = 8.dp),
877+ verticalAlignment = Alignment.CenterVertically,
879+ Column(modifier = Modifier.weight(1f)) {
880+ Text(notifPermissionLabel)
882+ notifPermissionDesc,
883+ style = MaterialTheme.typography.bodySmall,
884+ color = MaterialTheme.colorScheme.onSurfaceVariant,
887+ Switch(checked = notifPermission, onCheckedChange = null)
646− .padding(vertical = 8.dp),
889+ }
647− verticalAlignment = Alignment.CenterVertically,
890+ if (rowVisible(
648− ) {
891+ settingsQuery,
649− Column(modifier = Modifier.weight(1f)) {
892+ notificationsHeaderMatches,
650− Text(stringResource(R.string.settings_notif_error))
893+ notificationsAnyRowMatches,
651− Text(
894+ notifErrorLabel, notifErrorDesc,
652− stringResource(R.string.settings_notif_error_desc),
653− style = MaterialTheme.typography.bodySmall,
654− color = MaterialTheme.colorScheme.onSurfaceVariant,
897+ val notifScope = rememberCoroutineScope()
901+ .defaultMinSize(minHeight = 48.dp)
902+ .clickable(role = Role.Switch) {
903+ notifScope.launch { container.settingsStore.setNotifError(!notifError) }
905+ .padding(vertical = 8.dp),
906+ verticalAlignment = Alignment.CenterVertically,
908+ Column(modifier = Modifier.weight(1f)) {
909+ Text(notifErrorLabel)
912+ style = MaterialTheme.typography.bodySmall,
913+ color = MaterialTheme.colorScheme.onSurfaceVariant,
916+ Switch(checked = notifError, onCheckedChange = null)
657− Switch(checked = notifError, onCheckedChange = null)
921+ if (showNotifications) {
660− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
922+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
662 // Swipe actions: remappable left/right swipe on session list rows. Defaults
925 // Swipe actions: remappable left/right swipe on session list rows. Defaults
663 // match the prior hardcoded behavior (left=delete, right=archive). NONE
926 // match the prior hardcoded behavior (left=delete, right=archive). NONE
664 // disables the swipe entirely.
927 // disables the swipe entirely.
928+ if (showSwipeActions) {
666− stringResource(R.string.settings_swipe_actions),
930+ swipeActionsHeader,
667− style = MaterialTheme.typography.titleSmall,
931+ style = MaterialTheme.typography.titleSmall,
668− color = MaterialTheme.colorScheme.primary,
932+ color = MaterialTheme.colorScheme.primary,
669− modifier = Modifier.semantics { heading() },
933+ modifier = Modifier.semantics { heading() },
671− val swipeScope = rememberCoroutineScope()
672− val swipeOptions = remember {
674− SwipeAction.DELETE to R.string.swipe_action_delete,
675− SwipeAction.ARCHIVE to R.string.swipe_action_archive,
676− SwipeAction.MARK_READ to R.string.swipe_action_mark_read,
677− SwipeAction.NONE to R.string.swipe_action_none,
935+ val swipeScope = rememberCoroutineScope()
936+ val swipeOptions = remember {
938+ SwipeAction.DELETE to R.string.swipe_action_delete,
939+ SwipeAction.ARCHIVE to R.string.swipe_action_archive,
940+ SwipeAction.MARK_READ to R.string.swipe_action_mark_read,
941+ SwipeAction.NONE to R.string.swipe_action_none,
680− var swipeLeftMenu by remember { mutableStateOf(false) }
944+ var swipeLeftMenu by remember { mutableStateOf(false) }
681− var swipeRightMenu by remember { mutableStateOf(false) }
945+ var swipeRightMenu by remember { mutableStateOf(false) }
682− val swipeLeftParsed = remember(swipeLeftAction) {
946+ val swipeLeftParsed = remember(swipeLeftAction) {
683− runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
947+ runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
685− val swipeRightParsed = remember(swipeRightAction) {
949+ val swipeRightParsed = remember(swipeRightAction) {
686− runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
950+ runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
952+ if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeLeftLabel)) {
689− modifier = Modifier
954+ modifier = Modifier
690− .fillMaxWidth()
955+ .fillMaxWidth()
691− .defaultMinSize(minHeight = 48.dp)
956+ .defaultMinSize(minHeight = 48.dp)
692− .padding(vertical = 8.dp),
957+ .padding(vertical = 8.dp),
693− verticalAlignment = Alignment.CenterVertically,
958+ verticalAlignment = Alignment.CenterVertically,
695− Text(stringResource(R.string.settings_swipe_left), modifier = Modifier.weight(1f))
960+ Text(swipeLeftLabel, modifier = Modifier.weight(1f))
697− TextButton(onClick = { swipeLeftMenu = true }) {
962+ TextButton(onClick = { swipeLeftMenu = true }) {
698− Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
963+ Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
965+ DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
966+ swipeOptions.forEach { (action, label) ->
968+ text = { Text(stringResource(label)) },
970+ swipeLeftMenu = false
971+ swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
979+ if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeRightLabel)) {
983+ .defaultMinSize(minHeight = 48.dp)
984+ .padding(vertical = 8.dp),
985+ verticalAlignment = Alignment.CenterVertically,
987+ Text(swipeRightLabel, modifier = Modifier.weight(1f))
989+ TextButton(onClick = { swipeRightMenu = true }) {
990+ Text(stringResource(swipeOptions.first { it.first == swipeRightParsed }.second))
700− DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
992+ DropdownMenu(expanded = swipeRightMenu, onDismissRequest = { swipeRightMenu = false }) {
701− swipeOptions.forEach { (action, label) ->
993+ swipeOptions.forEach { (action, label) ->
702− DropdownMenuItem(
994+ DropdownMenuItem(
703− text = { Text(stringResource(label)) },
995+ text = { Text(stringResource(label)) },
704− onClick = {
996+ onClick = {
705− swipeLeftMenu = false
997+ swipeRightMenu = false
706− swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
998+ swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
714− modifier = Modifier
1008+ if (showSwipeActions) {
716− .defaultMinSize(minHeight = 48.dp)
717− .padding(vertical = 8.dp),
1009+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
718− verticalAlignment = Alignment.CenterVertically,
1010+ }
720− Text(stringResource(R.string.settings_swipe_right), modifier = Modifier.weight(1f))
1012+ if (showConnection) {
722− TextButton(onClick = { swipeRightMenu = true }) {
1014+ connectionHeader,
723− Text(stringResource(swipeOptions.first { it.first == swipeRightParsed }.second))
1015+ style = MaterialTheme.typography.titleSmall,
1016+ color = MaterialTheme.colorScheme.primary,
1017+ modifier = Modifier.semantics { heading() },
1019+ // The connection status block (profile label + URL + SSE state + retry) has no
1020+ // single stable label to match against, so show it whenever the section is visible.
1021+ if (activeProfile != null) {
1022+ Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
1024+ activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
1025+ style = MaterialTheme.typography.bodySmall,
1026+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1028+ // Surface the SSE stream state so a user who opens Settings during a
1029+ // reconnect saw it (the ConnectionBanner on other screens handles this
1030+ // elsewhere). Only show non-Connected states to avoid clutter. On a hard
1031+ // failure offer an inline Retry — without it a user who opened Settings
1032+ // during a failure is stuck on an error message with no recovery path
1033+ // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
1034+ // host it). Retry forces an SSE reconnect, which re-seeds from REST.
1035+ val stateText = when (connectionState) {
1036+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
1037+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
1038+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
1039+ EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
1040+ EventStreamClient.ConnectionState.Connected -> null
725− DropdownMenu(expanded = swipeRightMenu, onDismissRequest = { swipeRightMenu = false }) {
1042+ stateText?.let {
726− swipeOptions.forEach { (action, label) ->
1043+ Row(
727− DropdownMenuItem(
1044+ modifier = Modifier.padding(top = 4.dp),
728− text = { Text(stringResource(label)) },
1045+ verticalAlignment = Alignment.CenterVertically,
729− onClick = {
1046+ ) {
730− swipeRightMenu = false
1047+ val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
731− swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
1048+ connectionState == EventStreamClient.ConnectionState.AuthFailed
1051+ style = MaterialTheme.typography.bodySmall,
1052+ color = if (isHardFailure)
1053+ MaterialTheme.colorScheme.error
1054+ else MaterialTheme.colorScheme.onSurfaceVariant,
1056+ if (isHardFailure) {
1058+ onClick = { container.activeConnection.value?.events?.triggerReconnect() },
1059+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1060+ modifier = Modifier.defaultMinSize(minHeight = 48.dp),
1061+ ) { Text(stringResource(R.string.retry_now)) }
1067+ stringResource(R.string.not_connected),
1068+ modifier = Modifier.padding(top = 8.dp),
1069+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1072+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, manageServersLabel)) {
1074+ modifier = Modifier
1076+ .defaultMinSize(minHeight = 48.dp)
1077+ .padding(top = 8.dp)
1078+ .clickable(role = Role.Button) { onManageServers() }
1079+ .padding(vertical = 8.dp),
1080+ verticalAlignment = Alignment.CenterVertically,
1082+ Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
1083+ Text(manageServersLabel, modifier = Modifier.weight(1f).padding(start = 8.dp))
1084+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
1087+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, usageLabel)) {
1089+ icon = Icons.Filled.QueryStats,
1090+ label = usageLabel,
1091+ enabled = activeProfile != null,
1092+ onClick = onOpenUsage,
1095+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, mcpLabel)) {
1097+ icon = Icons.Filled.Hub,
1099+ enabled = activeProfile != null,
1100+ onClick = onOpenMcp,
1103+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, notifSettingsLabel)) {
1105+ icon = Icons.Filled.Notifications,
1106+ label = notifSettingsLabel,
1110+ val opened = runCatchingCancellable {
1111+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
1112+ .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
1113+ context.startActivity(intent)
1115+ if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
1122+ if (showConnection) {
739− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1123+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
741− Text(
1126+ if (showBackup) {
742− stringResource(R.string.connection),
743− style = MaterialTheme.typography.titleSmall,
744− color = MaterialTheme.colorScheme.primary,
745− modifier = Modifier.semantics { heading() },
747− if (activeProfile != null) {
748− Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
750− activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
1128+ backupHeader,
751− style = MaterialTheme.typography.bodySmall,
1129+ style = MaterialTheme.typography.titleSmall,
752− color = MaterialTheme.colorScheme.onSurfaceVariant,
1130+ color = MaterialTheme.colorScheme.primary,
1131+ modifier = Modifier.semantics { heading() },
754− // Surface the SSE stream state so a user who opens Settings during a
1133+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, backupDescText)) {
755− // reconnect saw it (the ConnectionBanner on other screens handles this
1134+ Text(
756− // elsewhere). Only show non-Connected states to avoid clutter. On a hard
1135+ backupDescText,
757− // failure offer an inline Retry — without it a user who opened Settings
1136+ style = MaterialTheme.typography.bodySmall,
758− // during a failure is stuck on an error message with no recovery path
1137+ color = MaterialTheme.colorScheme.onSurfaceVariant,
759− // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
1138+ modifier = Modifier.padding(top = 4.dp),
760− // host it). Retry forces an SSE reconnect, which re-seeds from REST.
1139+ )
761− val stateText = when (connectionState) {
762− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
763− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
764− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
765− EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
766− EventStreamClient.ConnectionState.Connected -> null
768− stateText?.let {
1141+ if (rowVisible(
1143+ backupHeaderMatches,
1144+ backupAnyRowMatches,
1145+ backupIncludePasswordsLabel, backupIncludePasswordsDesc,
770− modifier = Modifier.padding(top = 4.dp),
1149+ modifier = Modifier
1151+ .defaultMinSize(minHeight = 48.dp)
1153+ value = includePasswords,
1154+ onValueChange = { includePasswords = it },
1155+ role = Role.Switch,
1157+ .padding(vertical = 8.dp),
771 verticalAlignment = Alignment.CenterVertically,
1158 verticalAlignment = Alignment.CenterVertically,
773− val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
1160+ Column(modifier = Modifier.weight(1f)) {
774− connectionState == EventStreamClient.ConnectionState.AuthFailed
1161+ Text(backupIncludePasswordsLabel, style = MaterialTheme.typography.bodyLarge)
776− it,
1163+ backupIncludePasswordsDesc,
777− style = MaterialTheme.typography.bodySmall,
1164+ style = MaterialTheme.typography.bodySmall,
778− color = if (isHardFailure)
779− MaterialTheme.colorScheme.error
780− else MaterialTheme.colorScheme.onSurfaceVariant,
1165+ color = MaterialTheme.colorScheme.onSurfaceVariant,
782− if (isHardFailure) {
784− onClick = { container.activeConnection.value?.events?.triggerReconnect() },
785− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
786− modifier = Modifier.defaultMinSize(minHeight = 48.dp),
787− ) { Text(stringResource(R.string.retry_now)) }
1168+ Switch(checked = includePasswords, onCheckedChange = null)
791− } else {
1171+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, exportBackupLabel)) {
793− stringResource(R.string.not_connected),
1173+ icon = Icons.Filled.Upload,
794− modifier = Modifier.padding(top = 8.dp),
1174+ label = exportBackupLabel,
795− color = MaterialTheme.colorScheme.onSurfaceVariant,
1175+ enabled = !backupBusy,
1176+ // Warn before writing plaintext credentials out of the app; confirm then launches
1177+ // the file picker. Without passwords, export straight away.
1179+ if (includePasswords) showExportPasswordWarning = true
1180+ else runCatching { exportLauncher.launch(backupFilename()) }
1184+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, importBackupLabel)) {
1186+ icon = Icons.Filled.Download,
1187+ label = importBackupLabel,
1188+ enabled = !backupBusy,
1191+ importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
798− Row(
1196+ // Surface an inline progress indicator while a backup write/read is in flight so
1197+ // the user gets immediate feedback that the (potentially slow on a big file)
1198+ // operation is running, and isn't tempted to tap again.
799− modifier = Modifier
1200+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
800− .fillMaxWidth()
1201+ }
801− .defaultMinSize(minHeight = 48.dp)
802− .padding(top = 8.dp)
803− .clickable(role = Role.Button) { onManageServers() }
804− .padding(vertical = 8.dp),
805− verticalAlignment = Alignment.CenterVertically,
807− Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
808− Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
809− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
812− icon = Icons.Filled.QueryStats,
813− label = stringResource(R.string.usage_title),
814− enabled = activeProfile != null,
815− onClick = onOpenUsage,
818− icon = Icons.Filled.Hub,
819− label = stringResource(R.string.mcp_servers),
820− enabled = activeProfile != null,
821− onClick = onOpenMcp,
824− icon = Icons.Filled.Notifications,
825− label = stringResource(R.string.notification_settings),
829− val opened = runCatchingCancellable {
830− val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
831− .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
832− context.startActivity(intent)
834− if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
839− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1205+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
841− Text(
1208+ if (showAbout) {
842− stringResource(R.string.backup_restore),
1209+ Text(
843− style = MaterialTheme.typography.titleSmall,
1211+ style = MaterialTheme.typography.titleSmall,
844− color = MaterialTheme.colorScheme.primary,
1212+ color = MaterialTheme.colorScheme.primary,
845− modifier = Modifier.semantics { heading() },
1213+ modifier = Modifier.semantics { heading() },
847− Text(
1215+ // The version line is the About section's main content; show it whenever the
1216+ // section is visible (it has no separate label to filter on).
848− stringResource(R.string.backup_desc),
1218+ stringResource(R.string.about_version, versionName),
849− style = MaterialTheme.typography.bodySmall,
1219+ style = MaterialTheme.typography.bodyMedium,
850− color = MaterialTheme.colorScheme.onSurfaceVariant,
851− modifier = Modifier.padding(top = 4.dp),
1220+ modifier = Modifier.padding(top = 8.dp),
856− .defaultMinSize(minHeight = 48.dp)
858− value = includePasswords,
859− onValueChange = { includePasswords = it },
862− .padding(vertical = 8.dp),
863− verticalAlignment = Alignment.CenterVertically,
865− Column(modifier = Modifier.weight(1f)) {
1222+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, aboutDescText)) {
866− Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
868− stringResource(R.string.backup_include_passwords_desc),
1224+ aboutDescText,
869 style = MaterialTheme.typography.bodySmall,
1225 style = MaterialTheme.typography.bodySmall,
870 color = MaterialTheme.colorScheme.onSurfaceVariant,
1226 color = MaterialTheme.colorScheme.onSurfaceVariant,
1227+ modifier = Modifier.padding(top = 4.dp),
873− Switch(checked = includePasswords, onCheckedChange = null)
876− icon = Icons.Filled.Upload,
877− label = stringResource(R.string.export_backup),
878− enabled = !backupBusy,
879− // Warn before writing plaintext credentials out of the app; confirm then launches
880− // the file picker. Without passwords, export straight away.
882− if (includePasswords) showExportPasswordWarning = true
883− else runCatching { exportLauncher.launch(backupFilename()) }
887− icon = Icons.Filled.Download,
888− label = stringResource(R.string.import_backup),
889− enabled = !backupBusy,
892− importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
896− // Surface an inline progress indicator while a backup write/read is in flight so
897− // the user gets immediate feedback that the (potentially slow on a big file)
898− // operation is running, and isn't tempted to tap again.
900− LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
903− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
906− stringResource(R.string.about),
907− style = MaterialTheme.typography.titleSmall,
908− color = MaterialTheme.colorScheme.primary,
909− modifier = Modifier.semantics { heading() },
912− stringResource(R.string.about_version, versionName),
913− style = MaterialTheme.typography.bodyMedium,
914− modifier = Modifier.padding(top = 8.dp),
917− stringResource(R.string.about_desc),
918− style = MaterialTheme.typography.bodySmall,
919− color = MaterialTheme.colorScheme.onSurfaceVariant,
920− modifier = Modifier.padding(top = 4.dp),
1231+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, diagnosticsLabel)) {
923− Spacer(Modifier.size(16.dp))
1232+ Spacer(Modifier.size(16.dp))
925− modifier = Modifier
1234+ modifier = Modifier
926− .fillMaxWidth()
1235+ .fillMaxWidth()
927− .defaultMinSize(minHeight = 48.dp)
1236+ .defaultMinSize(minHeight = 48.dp)
928− .clickable(role = Role.Button) { onOpenDiagnostics() }
1237+ .clickable(role = Role.Button) { onOpenDiagnostics() }
929− .padding(vertical = 8.dp),
1238+ .padding(vertical = 8.dp),
930− verticalAlignment = Alignment.CenterVertically,
1239+ verticalAlignment = Alignment.CenterVertically,
932− Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
1241+ Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
933− Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
1242+ Text(diagnosticsLabel, modifier = Modifier.weight(1f).padding(start = 8.dp))
934− // Badge with the crash count so the user can tell at a glance whether
1243+ // Badge with the crash count so the user can tell at a glance whether
935− // there's something worth investigating. Uses M3 BadgedBox+Badge for
1244+ // there's something worth investigating. Uses M3 BadgedBox+Badge for
936− // consistency with the nav rail's badge (which already uses the real M3
1245+ // consistency with the nav rail's badge (which already uses the real M3
937− // components) instead of a hand-rolled Box.
1246+ // components) instead of a hand-rolled Box.
938− if (crashCount > 0) {
1247+ if (crashCount > 0) {
939− BadgedBox(
1248+ BadgedBox(
940− badge = {
1249+ badge = {
941− Badge {
1250+ Badge {
942− val overflow = stringResource(R.string.crash_count_overflow)
1251+ val overflow = stringResource(R.string.crash_count_overflow)
943− Text(if (crashCount > 99) overflow else crashCount.toString())
1252+ Text(if (crashCount > 99) overflow else crashCount.toString())
1256+ Spacer(Modifier.size(8.dp))
1258+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
1261+ // Discoverability links from the About section: source code and issue tracker.
1262+ // Opens in the browser via ACTION_VIEW; a device without a browser (e.g. a de-Googled
1263+ // or work-profile split) silently no-ops otherwise, so surface a snackbar on failure.
1264+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, sourceCodeLabel)) {
1266+ icon = Icons.Filled.Code,
1267+ label = sourceCodeLabel,
1271+ context.startActivity(
1272+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
1274+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
947− Spacer(Modifier.size(8.dp))
1277+ }
1278+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, reportIssueLabel)) {
1280+ icon = Icons.Filled.Feedback,
1281+ label = reportIssueLabel,
1285+ context.startActivity(
1286+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
1288+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
949− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
951− // Discoverability links from the About section: source code and issue tracker.
952− // Opens in the browser via ACTION_VIEW; a device without a browser (e.g. a de-Googled
953− // or work-profile split) silently no-ops otherwise, so surface a snackbar on failure.
955− icon = Icons.Filled.Code,
956− label = stringResource(R.string.source_code),
960− context.startActivity(
961− Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
963− }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
967− icon = Icons.Filled.Feedback,
968− label = stringResource(R.string.report_issue),
972− context.startActivity(
973− Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
975− }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
@@ -1002,13 +1317,25 @@ fun SettingsScreen(
1003 if (result == SnackbarResult.ActionPerformed) {
1318 if (result == SnackbarResult.ActionPerformed) {
1004 // Shut down the container (cancels the app scope, closes the
1319 // Shut down the container (cancels the app scope, closes the
1005− // active connection, flushes stores) before the hard kill so
1320+ // active connection, flushes stores) before restarting so
1006 // in-flight work is cleaned up rather than abruptly severed.
1321 // in-flight work is cleaned up rather than abruptly severed.
1007− // The JVM shutdown hook in OpencodeApp would catch this, but
1008− // calling shutdown explicitly is cleaner and avoids relying on
1009− // the hook's ordering under a kill.
1010 runCatchingCancellable { container.shutdown() }
1322 runCatchingCancellable { container.shutdown() }
1323+ // Graceful restart: relaunch the app's main activity via the
1324+ // launcher intent and finish the current task, instead of
1325+ // killing the process (which the system may log as a crash
1326+ // and which skips normal lifecycle teardown). Falling back to
1327+ // a hard kill only when no launch intent is resolvable.
1328+ val pm = context.packageManager
1329+ val intent = pm.getLaunchIntentForPackage(context.packageName)
1330+ if (intent != null) {
1332+ android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
1333+ android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK,
1335+ context.startActivity(intent)
1011− android.os.Process.killProcess(android.os.Process.myPid())
1337+ android.os.Process.killProcess(android.os.Process.myPid())
1013 } else {
1340 } else {
1014 snackbar.showSnackbar(importFailedMsg)
1341 snackbar.showSnackbar(importFailedMsg)
@@ -1209,6 +1536,34 @@ private val AmoledSwatches: List<Color> = AmoledPaletteSwatches
1209@Composable
1536@Composable
1210private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
1537private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
1540+ * Case-insensitive substring match against any of [texts]. An empty query matches everything
1541+ * (so the search filter is a no-op when the field is cleared). Used to gate section/row
1542+ * visibility in the settings list.
1544+private fun matchesQuery(query: String, vararg texts: String): Boolean {
1545+ if (query.isEmpty()) return true
1546+ val q = query.trim().lowercase()
1547+ if (q.isEmpty()) return true
1548+ return texts.any { it.lowercase().contains(q) }
1552+ * Per-row visibility given the section's precomputed header/any-row match state. When the
1553+ * header matched but no row did, the whole section is browsable so every row is shown;
1554+ * otherwise a row shows only when its own texts match the query.
1556+private fun rowVisible(
1558+ headerMatches: Boolean,
1559+ anyRowMatches: Boolean,
1560+ vararg rowTexts: String,
1562+ if (query.isEmpty()) return true
1563+ if (headerMatches && !anyRowMatches) return true
1564+ return matchesQuery(query, *rowTexts)
1212/** Small wrapper so the three appearance settings load atomically and the UI can gate
1567/** Small wrapper so the three appearance settings load atomically and the UI can gate
1213 * on a single null check instead of flashing hardcoded defaults. */
1568 * on a single null check instead of flashing hardcoded defaults. */
1214private data class SettingsValues(
1569private data class SettingsValues(