@@ -22,12 +22,15 @@ import androidx.compose.foundation.layout.wrapContentWidth
2222import androidx.compose.foundation.rememberScrollState
2323import androidx.compose.foundation.selection.selectable
2424import androidx.compose.foundation.selection.toggleable
25+import androidx.compose.foundation.text.KeyboardActions
26+import androidx.compose.foundation.text.KeyboardOptions
2527import androidx.compose.foundation.verticalScroll
2628import androidx.compose.foundation.shape.CircleShape
2729import androidx.compose.material.icons.Icons
2830import androidx.compose.material.icons.automirrored.filled.ArrowBack
2931import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
3032import androidx.compose.material.icons.filled.BugReport
33+import androidx.compose.material.icons.filled.Clear
3134import androidx.compose.material.icons.filled.Code
3235import androidx.compose.material.icons.filled.Dns
3336import androidx.compose.material.icons.filled.Download
@@ -35,6 +38,7 @@ import androidx.compose.material.icons.filled.Feedback
3538import androidx.compose.material.icons.filled.Hub
3639import androidx.compose.material.icons.filled.Notifications
3740import androidx.compose.material.icons.filled.QueryStats
41+import androidx.compose.material.icons.filled.Search
3842import androidx.compose.material.icons.filled.Upload
3943import androidx.compose.material3.AlertDialog
4044import androidx.compose.material3.ExperimentalMaterial3Api
@@ -78,12 +82,15 @@ import androidx.compose.ui.graphics.Color
7882import androidx.compose.ui.input.nestedscroll.nestedScroll
7983import androidx.compose.ui.platform.LocalContext
8084import androidx.compose.ui.platform.LocalDensity
85+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
86+import androidx.compose.ui.platform.testTag
8187import androidx.compose.ui.res.pluralStringResource
8288import androidx.compose.ui.res.stringResource
8389import androidx.compose.ui.semantics.Role
8490import androidx.compose.ui.semantics.heading
8591import androidx.compose.ui.semantics.role
8692import androidx.compose.ui.semantics.semantics
93+import androidx.compose.ui.text.input.ImeAction
8794import androidx.compose.ui.text.style.TextAlign
8895import androidx.compose.ui.unit.dp
8996import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -202,6 +209,9 @@ fun SettingsScreen(
202209 // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
203210 // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
204211 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("") }
205215 // True while an export or import is in flight. Used to disable both NavRow triggers so a
206216 // double-tap (or a slow device) can't launch a second file picker or start a second
207217 // operation while the first is still reading/writing. Mirrors the busy-flag pattern used
@@ -283,698 +293,1003 @@ fun SettingsScreen(
283293 .padding(16.dp),
284294 ) {
285295 val s = settings
286− Text(
287− stringResource(R.string.appearance),
288− style = MaterialTheme.typography.titleSmall,
289− color = MaterialTheme.colorScheme.primary,
290− modifier = Modifier.semantics { heading() },
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.
299+ val appearanceHeader = stringResource(R.string.appearance)
300+ val textDisplayHeader = stringResource(R.string.text_display)
301+ val motionHeader = stringResource(R.string.settings_motion)
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)
347+
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(
356+ settingsQuery,
357+ dynamicColorLabel, dynamicColorDesc,
358+ sendOnEnterLabel, sendOnEnterDesc,
359+ appLockLabel, appLockDesc,
291360 )
292− if (s != null) {
293− ThemeMode.entries.forEach { mode ->
294− ThemeRow(
295− mode = mode,
296− selected = s.themeMode == mode,
297− // When Material You is active the static Tokyo Night swatches would be a
298− // lie, so ThemeRow hides them and notes that system colors are in use.
299− usingSystemColors = s.dynamicColor && dynamicColorAvailable,
300− onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
301− )
302− }
361+ val showAppearance = appearanceHeaderMatches || appearanceAnyRowMatches
362+ val textDisplayHeaderMatches = matchesQuery(settingsQuery, textDisplayHeader)
363+ val textDisplayAnyRowMatches = matchesQuery(
364+ settingsQuery,
365+ wrapCodeLabel, wrapCodeDesc,
366+ compactSpacingLabel, compactSpacingDesc,
367+ chatTextSizeDesc,
368+ )
369+ val showTextDisplay = textDisplayHeaderMatches || textDisplayAnyRowMatches
370+ val motionHeaderMatches = matchesQuery(settingsQuery, motionHeader)
371+ val motionAnyRowMatches = matchesQuery(
372+ settingsQuery,
373+ hapticsLabel, hapticsDesc,
374+ reducedMotionLabel, reducedMotionDesc,
375+ languageLabel, languageDesc,
376+ )
377+ val showMotion = motionHeaderMatches || motionAnyRowMatches
378+ val notificationsHeaderMatches = matchesQuery(settingsQuery, notificationsHeader)
379+ val notificationsAnyRowMatches = matchesQuery(
380+ settingsQuery,
381+ notifRunCompleteLabel, notifRunCompleteDesc,
382+ notifPermissionLabel, notifPermissionDesc,
383+ notifErrorLabel, notifErrorDesc,
384+ )
385+ val showNotifications = notificationsHeaderMatches || notificationsAnyRowMatches
386+ val swipeHeaderMatches = matchesQuery(settingsQuery, swipeActionsHeader)
387+ val swipeAnyRowMatches = matchesQuery(
388+ settingsQuery,
389+ swipeLeftLabel,
390+ swipeRightLabel,
391+ )
392+ val showSwipeActions = swipeHeaderMatches || swipeAnyRowMatches
393+ val connectionHeaderMatches = matchesQuery(settingsQuery, connectionHeader)
394+ val connectionAnyRowMatches = matchesQuery(
395+ settingsQuery,
396+ manageServersLabel,
397+ usageLabel,
398+ mcpLabel,
399+ notifSettingsLabel,
400+ )
401+ val showConnection = connectionHeaderMatches || connectionAnyRowMatches
402+ val backupHeaderMatches = matchesQuery(settingsQuery, backupHeader)
403+ val backupAnyRowMatches = matchesQuery(
404+ settingsQuery,
405+ backupDescText,
406+ backupIncludePasswordsLabel, backupIncludePasswordsDesc,
407+ exportBackupLabel,
408+ importBackupLabel,
409+ )
410+ val showBackup = backupHeaderMatches || backupAnyRowMatches
411+ val aboutHeaderMatches = matchesQuery(settingsQuery, aboutHeader)
412+ val aboutAnyRowMatches = matchesQuery(
413+ settingsQuery,
414+ aboutDescText,
415+ diagnosticsLabel,
416+ sourceCodeLabel,
417+ reportIssueLabel,
418+ )
419+ val showAbout = aboutHeaderMatches || aboutAnyRowMatches
303420
304− Spacer(Modifier.size(8.dp))
305− // Dynamic color (Material You) only works on Android 12+, so hide the toggle
306− // on older devices instead of offering a control that silently does nothing.
307− if (dynamicColorAvailable) {
308− val amoledActive = s.themeMode == ThemeMode.AMOLED
309− Row(
310− modifier = Modifier
311− .fillMaxWidth()
312− .defaultMinSize(minHeight = 48.dp)
313− .toggleable(
314− value = s.dynamicColor,
315− enabled = !amoledActive,
316− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
317− role = Role.Switch,
421+ val keyboardController = LocalSoftwareKeyboardController.current
422+ OutlinedTextField(
423+ value = settingsQuery,
424+ onValueChange = { settingsQuery = it },
425+ modifier = Modifier
426+ .fillMaxWidth()
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()) {
433+ {
434+ IconButton(onClick = { settingsQuery = "" }) {
435+ Icon(Icons.Filled.Clear, contentDescription = stringResource(R.string.clear_search))
436+ }
437+ }
438+ } else null,
439+ singleLine = true,
440+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
441+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
442+ )
443+
444+ if (showAppearance) {
445+ Text(
446+ appearanceHeader,
447+ style = MaterialTheme.typography.titleSmall,
448+ color = MaterialTheme.colorScheme.primary,
449+ modifier = Modifier.semantics { heading() },
450+ )
451+ if (s != null) {
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)
461+ }
462+ if (rowVisible(settingsQuery, appearanceHeaderMatches, appearanceAnyRowMatches, modeLabel)) {
463+ ThemeRow(
464+ mode = mode,
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.
468+ usingSystemColors = s.dynamicColor && dynamicColorAvailable,
469+ onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
318470 )
319− .padding(vertical = 8.dp),
320− verticalAlignment = Alignment.CenterVertically,
471+ }
472+ }
473+
474+ if (dynamicColorAvailable && rowVisible(
475+ settingsQuery,
476+ appearanceHeaderMatches,
477+ appearanceAnyRowMatches,
478+ dynamicColorLabel, dynamicColorDesc,
479+ )
321480 ) {
322− Column(modifier = Modifier.weight(1f)) {
323− Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
324− Text(
325− stringResource(R.string.dynamic_color_desc),
326− style = MaterialTheme.typography.bodySmall,
327− color = MaterialTheme.colorScheme.onSurfaceVariant,
328− )
329− if (amoledActive) {
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
485+ Row(
486+ modifier = Modifier
487+ .fillMaxWidth()
488+ .defaultMinSize(minHeight = 48.dp)
489+ .toggleable(
490+ value = s.dynamicColor,
491+ enabled = !amoledActive,
492+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
493+ role = Role.Switch,
494+ )
495+ .padding(vertical = 8.dp),
496+ verticalAlignment = Alignment.CenterVertically,
497+ ) {
498+ Column(modifier = Modifier.weight(1f)) {
499+ Text(dynamicColorLabel, style = MaterialTheme.typography.bodyLarge)
330500 Text(
331− stringResource(R.string.dynamic_color_disabled_amoled),
501+ dynamicColorDesc,
332502 style = MaterialTheme.typography.bodySmall,
333503 color = MaterialTheme.colorScheme.onSurfaceVariant,
334504 )
505+ if (amoledActive) {
506+ Text(
507+ stringResource(R.string.dynamic_color_disabled_amoled),
508+ style = MaterialTheme.typography.bodySmall,
509+ color = MaterialTheme.colorScheme.onSurfaceVariant,
510+ )
511+ }
335512 }
513+ Switch(
514+ checked = s.dynamicColor,
515+ onCheckedChange = null,
516+ enabled = !amoledActive,
517+ )
336518 }
337− Switch(
338− checked = s.dynamicColor,
339− onCheckedChange = null,
340− enabled = !amoledActive,
341− )
342519 }
343− }
344520
345− // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
346− Spacer(Modifier.size(4.dp))
347− Row(
348− modifier = Modifier
349− .fillMaxWidth()
350− .defaultMinSize(minHeight = 48.dp)
351− .toggleable(
352− value = s.sendOnEnter,
353− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
354− role = Role.Switch,
355− )
356− .padding(vertical = 8.dp),
357− verticalAlignment = Alignment.CenterVertically,
358− ) {
359− Column(modifier = Modifier.weight(1f)) {
360− Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
361− Text(
362− stringResource(R.string.send_on_enter_desc),
363− style = MaterialTheme.typography.bodySmall,
364− color = MaterialTheme.colorScheme.onSurfaceVariant,
521+ // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
522+ if (rowVisible(
523+ settingsQuery,
524+ appearanceHeaderMatches,
525+ appearanceAnyRowMatches,
526+ sendOnEnterLabel, sendOnEnterDesc,
365527 )
528+ ) {
529+ Spacer(Modifier.size(4.dp))
530+ Row(
531+ modifier = Modifier
532+ .fillMaxWidth()
533+ .defaultMinSize(minHeight = 48.dp)
534+ .toggleable(
535+ value = s.sendOnEnter,
536+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
537+ role = Role.Switch,
538+ )
539+ .padding(vertical = 8.dp),
540+ verticalAlignment = Alignment.CenterVertically,
541+ ) {
542+ Column(modifier = Modifier.weight(1f)) {
543+ Text(sendOnEnterLabel, style = MaterialTheme.typography.bodyLarge)
544+ Text(
545+ sendOnEnterDesc,
546+ style = MaterialTheme.typography.bodySmall,
547+ color = MaterialTheme.colorScheme.onSurfaceVariant,
548+ )
549+ }
550+ Switch(
551+ checked = s.sendOnEnter,
552+ onCheckedChange = null,
553+ )
554+ }
366555 }
367− Switch(
368− checked = s.sendOnEnter,
369− onCheckedChange = null,
370− )
371− }
372556
373− // 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
375− // a biometric or device credential is actually available.
376− val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
377− val appLockToggleable = appLockAvailable || s.appLock
378− Spacer(Modifier.size(4.dp))
379− Row(
380− modifier = Modifier
381− .fillMaxWidth()
382− .defaultMinSize(minHeight = 48.dp)
383− .toggleable(
384− value = s.appLock,
385− enabled = appLockToggleable,
386− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
387− role = Role.Switch,
388− )
389− .padding(vertical = 8.dp),
390− verticalAlignment = Alignment.CenterVertically,
391− ) {
392− Column(modifier = Modifier.weight(1f)) {
393− Text(stringResource(R.string.app_lock), style = MaterialTheme.typography.bodyLarge)
394− Text(
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,
557+ // App lock: gate on device capability. Always allow turning it *off* (in case
558+ // the enrolled biometric was later removed), but only allow turning it on when
559+ // a biometric or device credential is actually available.
560+ if (rowVisible(
561+ settingsQuery,
562+ appearanceHeaderMatches,
563+ appearanceAnyRowMatches,
564+ appLockLabel, appLockDesc,
398565 )
566+ ) {
567+ val appLockAvailable = remember(biometricCheckTick) { canAuthenticateForAppLock(context) }
568+ val appLockToggleable = appLockAvailable || s.appLock
569+ Spacer(Modifier.size(4.dp))
570+ Row(
571+ modifier = Modifier
572+ .fillMaxWidth()
573+ .defaultMinSize(minHeight = 48.dp)
574+ .toggleable(
575+ value = s.appLock,
576+ enabled = appLockToggleable,
577+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
578+ role = Role.Switch,
579+ )
580+ .padding(vertical = 8.dp),
581+ verticalAlignment = Alignment.CenterVertically,
582+ ) {
583+ Column(modifier = Modifier.weight(1f)) {
584+ Text(appLockLabel, style = MaterialTheme.typography.bodyLarge)
585+ Text(
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,
589+ )
590+ }
591+ Switch(
592+ checked = s.appLock,
593+ onCheckedChange = null,
594+ enabled = appLockToggleable,
595+ )
596+ }
597+ if (s.appLock) {
598+ AppLockReLockSelector(
599+ seconds = appLockReLockSeconds,
600+ onSelect = { sec ->
601+ scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
602+ },
603+ )
604+ }
605+ }
606+ } else {
607+ Box(
608+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
609+ contentAlignment = Alignment.Center,
610+ ) {
611+ CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
399612 }
400− Switch(
401− checked = s.appLock,
402− onCheckedChange = null,
403− enabled = appLockToggleable,
404− )
405− }
406− if (s.appLock) {
407− AppLockReLockSelector(
408− seconds = appLockReLockSeconds,
409− onSelect = { sec ->
410− scope.launch { runCatchingCancellable { container.settingsStore.setAppLockReLockSeconds(sec) } }
411− },
412− )
413− }
414− } else {
415− Box(
416− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
417− contentAlignment = Alignment.Center,
418− ) {
419− CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
420613 }
421614 }
422615
423− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
616+ if (showAppearance) {
617+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
618+ }
424619
425− Text(
426− stringResource(R.string.text_display),
427− style = MaterialTheme.typography.titleSmall,
428− color = MaterialTheme.colorScheme.primary,
429− modifier = Modifier.semantics { heading() },
430− )
431− ChatTextSizeControl(
432− scale = chatTextScale,
433− onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
434− )
435− Spacer(Modifier.size(4.dp))
436− Row(
437− modifier = Modifier
438− .fillMaxWidth()
439− .defaultMinSize(minHeight = 48.dp)
440− .toggleable(
441− value = codeWrap,
442− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
443− role = Role.Switch,
620+ if (showTextDisplay) {
621+ Text(
622+ textDisplayHeader,
623+ style = MaterialTheme.typography.titleSmall,
624+ color = MaterialTheme.colorScheme.primary,
625+ modifier = Modifier.semantics { heading() },
626+ )
627+ if (rowVisible(settingsQuery, textDisplayHeaderMatches, textDisplayAnyRowMatches, chatTextSizeDesc)) {
628+ ChatTextSizeControl(
629+ scale = chatTextScale,
630+ onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
444631 )
445− .padding(vertical = 8.dp),
446− verticalAlignment = Alignment.CenterVertically,
447− ) {
448− Column(modifier = Modifier.weight(1f)) {
449− Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
450− Text(
451− stringResource(R.string.wrap_code_blocks_desc),
452− style = MaterialTheme.typography.bodySmall,
453− color = MaterialTheme.colorScheme.onSurfaceVariant,
632+ }
633+ if (rowVisible(
634+ settingsQuery,
635+ textDisplayHeaderMatches,
636+ textDisplayAnyRowMatches,
637+ wrapCodeLabel, wrapCodeDesc,
454638 )
639+ ) {
640+ Spacer(Modifier.size(4.dp))
641+ Row(
642+ modifier = Modifier
643+ .fillMaxWidth()
644+ .defaultMinSize(minHeight = 48.dp)
645+ .toggleable(
646+ value = codeWrap,
647+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
648+ role = Role.Switch,
649+ )
650+ .padding(vertical = 8.dp),
651+ verticalAlignment = Alignment.CenterVertically,
652+ ) {
653+ Column(modifier = Modifier.weight(1f)) {
654+ Text(wrapCodeLabel, style = MaterialTheme.typography.bodyLarge)
655+ Text(
656+ wrapCodeDesc,
657+ style = MaterialTheme.typography.bodySmall,
658+ color = MaterialTheme.colorScheme.onSurfaceVariant,
659+ )
660+ }
661+ Switch(checked = codeWrap, onCheckedChange = null)
662+ }
455663 }
456− Switch(checked = codeWrap, onCheckedChange = null)
457− }
458664
459− // Compact message spacing: a power-user toggle to fit more conversation on screen.
460− Spacer(Modifier.size(4.dp))
461− Row(
462− modifier = Modifier
463− .fillMaxWidth()
464− .defaultMinSize(minHeight = 48.dp)
465− .toggleable(
466− value = compactSpacing,
467− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
468− role = Role.Switch,
469− )
470− .padding(vertical = 8.dp),
471− verticalAlignment = Alignment.CenterVertically,
472− ) {
473− Column(modifier = Modifier.weight(1f)) {
474− Text(stringResource(R.string.compact_message_spacing), style = MaterialTheme.typography.bodyLarge)
475− Text(
476− stringResource(R.string.compact_message_spacing_desc),
477− style = MaterialTheme.typography.bodySmall,
478− color = MaterialTheme.colorScheme.onSurfaceVariant,
665+ // Compact message spacing: a power-user toggle to fit more conversation on screen.
666+ if (rowVisible(
667+ settingsQuery,
668+ textDisplayHeaderMatches,
669+ textDisplayAnyRowMatches,
670+ compactSpacingLabel, compactSpacingDesc,
479671 )
672+ ) {
673+ Spacer(Modifier.size(4.dp))
674+ Row(
675+ modifier = Modifier
676+ .fillMaxWidth()
677+ .defaultMinSize(minHeight = 48.dp)
678+ .toggleable(
679+ value = compactSpacing,
680+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCompactMessageSpacing(it) } } },
681+ role = Role.Switch,
682+ )
683+ .padding(vertical = 8.dp),
684+ verticalAlignment = Alignment.CenterVertically,
685+ ) {
686+ Column(modifier = Modifier.weight(1f)) {
687+ Text(compactSpacingLabel, style = MaterialTheme.typography.bodyLarge)
688+ Text(
689+ compactSpacingDesc,
690+ style = MaterialTheme.typography.bodySmall,
691+ color = MaterialTheme.colorScheme.onSurfaceVariant,
692+ )
693+ }
694+ Switch(checked = compactSpacing, onCheckedChange = null)
695+ }
480696 }
481− Switch(checked = compactSpacing, onCheckedChange = null)
482697 }
483698
484− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
699+ if (showTextDisplay) {
700+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
701+ }
485702
486703 // Motion & feedback: in-app toggles for haptics and reduced motion. The app
487704 // already honors the system Developer Options animator scale for reduced motion,
488705 // but this gives users an in-app control without disabling motion OS-wide.
489706 // Haptics default on (the existing behavior); an in-app toggle lets users
490707 // disable them without turning off system vibration.
491− Text(
492− stringResource(R.string.settings_motion),
493− style = MaterialTheme.typography.titleSmall,
494− color = MaterialTheme.colorScheme.primary,
495− modifier = Modifier.semantics { heading() },
496− )
497− val hapticsScope = rememberCoroutineScope()
498− Row(
499− modifier = Modifier
500− .fillMaxWidth()
501− .defaultMinSize(minHeight = 48.dp)
502− .clickable(role = Role.Switch) {
503− hapticsScope.launch {
504− container.settingsStore.setHapticsEnabled(!hapticsEnabled)
708+ if (showMotion) {
709+ Text(
710+ motionHeader,
711+ style = MaterialTheme.typography.titleSmall,
712+ color = MaterialTheme.colorScheme.primary,
713+ modifier = Modifier.semantics { heading() },
714+ )
715+ if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, hapticsLabel, hapticsDesc)) {
716+ val hapticsScope = rememberCoroutineScope()
717+ Row(
718+ modifier = Modifier
719+ .fillMaxWidth()
720+ .defaultMinSize(minHeight = 48.dp)
721+ .clickable(role = Role.Switch) {
722+ hapticsScope.launch {
723+ container.settingsStore.setHapticsEnabled(!hapticsEnabled)
724+ }
725+ }
726+ .padding(vertical = 8.dp),
727+ verticalAlignment = Alignment.CenterVertically,
728+ ) {
729+ Column(modifier = Modifier.weight(1f)) {
730+ Text(hapticsLabel)
731+ Text(
732+ hapticsDesc,
733+ style = MaterialTheme.typography.bodySmall,
734+ color = MaterialTheme.colorScheme.onSurfaceVariant,
735+ )
505736 }
737+ Switch(checked = hapticsEnabled, onCheckedChange = null)
506738 }
507− .padding(vertical = 8.dp),
508− verticalAlignment = Alignment.CenterVertically,
509− ) {
510− Column(modifier = Modifier.weight(1f)) {
511− Text(stringResource(R.string.settings_haptics))
512− Text(
513− stringResource(R.string.settings_haptics_desc),
514− style = MaterialTheme.typography.bodySmall,
515− color = MaterialTheme.colorScheme.onSurfaceVariant,
516− )
517739 }
518− Switch(checked = hapticsEnabled, onCheckedChange = null)
519− }
520− val motionScope = rememberCoroutineScope()
521− Row(
522− modifier = Modifier
523− .fillMaxWidth()
524− .defaultMinSize(minHeight = 48.dp)
525− .clickable(role = Role.Switch) {
526− motionScope.launch {
527− container.settingsStore.setReducedMotion(!reducedMotion)
740+ if (rowVisible(
741+ settingsQuery,
742+ motionHeaderMatches,
743+ motionAnyRowMatches,
744+ reducedMotionLabel, reducedMotionDesc,
745+ )
746+ ) {
747+ val motionScope = rememberCoroutineScope()
748+ Row(
749+ modifier = Modifier
750+ .fillMaxWidth()
751+ .defaultMinSize(minHeight = 48.dp)
752+ .clickable(role = Role.Switch) {
753+ motionScope.launch {
754+ container.settingsStore.setReducedMotion(!reducedMotion)
755+ }
756+ }
757+ .padding(vertical = 8.dp),
758+ verticalAlignment = Alignment.CenterVertically,
759+ ) {
760+ Column(modifier = Modifier.weight(1f)) {
761+ Text(reducedMotionLabel)
762+ Text(
763+ reducedMotionDesc,
764+ style = MaterialTheme.typography.bodySmall,
765+ color = MaterialTheme.colorScheme.onSurfaceVariant,
766+ )
528767 }
768+ Switch(checked = reducedMotion, onCheckedChange = null)
529769 }
530− .padding(vertical = 8.dp),
531− verticalAlignment = Alignment.CenterVertically,
532− ) {
533− Column(modifier = Modifier.weight(1f)) {
534− Text(stringResource(R.string.settings_reduced_motion))
535− Text(
536− stringResource(R.string.settings_reduced_motion_desc),
537− style = MaterialTheme.typography.bodySmall,
538− color = MaterialTheme.colorScheme.onSurfaceVariant,
539− )
540770 }
541− Switch(checked = reducedMotion, onCheckedChange = null)
542− }
543− // 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
545− // per-app language API; on older versions a restart is needed.
546− val langScope = rememberCoroutineScope()
547− Row(
548− modifier = Modifier
549− .fillMaxWidth()
550− .defaultMinSize(minHeight = 48.dp)
551− .padding(vertical = 8.dp),
552− verticalAlignment = Alignment.CenterVertically,
553− ) {
554− Column(modifier = Modifier.weight(1f)) {
555− Text(stringResource(R.string.settings_language))
556− Text(
557− if (languageOverride.isEmpty()) stringResource(R.string.settings_language_desc)
558− else languageOverride,
559− style = MaterialTheme.typography.bodySmall,
560− color = MaterialTheme.colorScheme.onSurfaceVariant,
561− )
562− }
563− val langOptions = remember {
564− listOf("" to R.string.settings_language_system, "en" to R.string.app_name, "es" to R.string.app_name)
565− }
566− var langMenu by remember { mutableStateOf(false) }
567− Box {
568− TextButton(onClick = { langMenu = true }) {
569− Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
570− }
571− DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
572− langOptions.forEach { (code, _) ->
573− DropdownMenuItem(
574− text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
575− onClick = {
576− langMenu = false
577− langScope.launch {
578− container.settingsStore.setLanguageOverride(code)
579− }
580− },
771+ // Language override: an in-app language picker so a user can use the app in a
772+ // different language without changing the system locale. Uses Android 13's
773+ // per-app language API; on older versions a restart is needed.
774+ if (rowVisible(settingsQuery, motionHeaderMatches, motionAnyRowMatches, languageLabel, languageDesc)) {
775+ val langScope = rememberCoroutineScope()
776+ Row(
777+ modifier = Modifier
778+ .fillMaxWidth()
779+ .defaultMinSize(minHeight = 48.dp)
780+ .padding(vertical = 8.dp),
781+ verticalAlignment = Alignment.CenterVertically,
782+ ) {
783+ Column(modifier = Modifier.weight(1f)) {
784+ Text(languageLabel)
785+ Text(
786+ if (languageOverride.isEmpty()) languageDesc
787+ else languageOverride,
788+ style = MaterialTheme.typography.bodySmall,
789+ color = MaterialTheme.colorScheme.onSurfaceVariant,
581790 )
582791 }
792+ val langOptions = remember {
793+ listOf("" to R.string.settings_language_system, "en" to R.string.app_name, "es" to R.string.app_name)
794+ }
795+ var langMenu by remember { mutableStateOf(false) }
796+ Box {
797+ TextButton(onClick = { langMenu = true }) {
798+ Text(if (languageOverride.isEmpty()) stringResource(R.string.settings_language_system) else languageOverride)
799+ }
800+ DropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
801+ langOptions.forEach { (code, _) ->
802+ DropdownMenuItem(
803+ text = { Text(if (code.isEmpty()) stringResource(R.string.settings_language_system) else code) },
804+ onClick = {
805+ langMenu = false
806+ langScope.launch {
807+ container.settingsStore.setLanguageOverride(code)
808+ }
809+ },
810+ )
811+ }
812+ }
813+ }
583814 }
584815 }
585816 }
586817
587− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
818+ if (showMotion) {
819+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
820+ }
588821
589822 // Notifications: granular toggles for each notification type, so a user can
590823 // mute specific types in-app (in addition to the OS channel controls). Each
591824 // toggle gates whether the app posts that notification type at all.
592− Text(
593− stringResource(R.string.settings_notifications),
594− style = MaterialTheme.typography.titleSmall,
595− color = MaterialTheme.colorScheme.primary,
596− modifier = Modifier.semantics { heading() },
597− )
598− val notifScope = rememberCoroutineScope()
599− Row(
600− modifier = Modifier
601− .fillMaxWidth()
602− .defaultMinSize(minHeight = 48.dp)
603− .clickable(role = Role.Switch) {
604− notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
605− }
606− .padding(vertical = 8.dp),
607− verticalAlignment = Alignment.CenterVertically,
608− ) {
609− Column(modifier = Modifier.weight(1f)) {
610− Text(stringResource(R.string.settings_notif_run_complete))
611− Text(
612− stringResource(R.string.settings_notif_run_complete_desc),
613− style = MaterialTheme.typography.bodySmall,
614− color = MaterialTheme.colorScheme.onSurfaceVariant,
825+ if (showNotifications) {
826+ Text(
827+ notificationsHeader,
828+ style = MaterialTheme.typography.titleSmall,
829+ color = MaterialTheme.colorScheme.primary,
830+ modifier = Modifier.semantics { heading() },
831+ )
832+ if (rowVisible(
833+ settingsQuery,
834+ notificationsHeaderMatches,
835+ notificationsAnyRowMatches,
836+ notifRunCompleteLabel, notifRunCompleteDesc,
615837 )
616− }
617− Switch(checked = notifRunComplete, onCheckedChange = null)
618− }
619− Row(
620− modifier = Modifier
621− .fillMaxWidth()
622− .defaultMinSize(minHeight = 48.dp)
623− .clickable(role = Role.Switch) {
624− notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
838+ ) {
839+ val notifScope = rememberCoroutineScope()
840+ Row(
841+ modifier = Modifier
842+ .fillMaxWidth()
843+ .defaultMinSize(minHeight = 48.dp)
844+ .clickable(role = Role.Switch) {
845+ notifScope.launch { container.settingsStore.setNotifRunComplete(!notifRunComplete) }
846+ }
847+ .padding(vertical = 8.dp),
848+ verticalAlignment = Alignment.CenterVertically,
849+ ) {
850+ Column(modifier = Modifier.weight(1f)) {
851+ Text(notifRunCompleteLabel)
852+ Text(
853+ notifRunCompleteDesc,
854+ style = MaterialTheme.typography.bodySmall,
855+ color = MaterialTheme.colorScheme.onSurfaceVariant,
856+ )
857+ }
858+ Switch(checked = notifRunComplete, onCheckedChange = null)
625859 }
626− .padding(vertical = 8.dp),
627− verticalAlignment = Alignment.CenterVertically,
628− ) {
629− Column(modifier = Modifier.weight(1f)) {
630− Text(stringResource(R.string.settings_notif_permission))
631− Text(
632− stringResource(R.string.settings_notif_permission_desc),
633− style = MaterialTheme.typography.bodySmall,
634− color = MaterialTheme.colorScheme.onSurfaceVariant,
635− )
636860 }
637− Switch(checked = notifPermission, onCheckedChange = null)
638− }
639− Row(
640− modifier = Modifier
641− .fillMaxWidth()
642− .defaultMinSize(minHeight = 48.dp)
643− .clickable(role = Role.Switch) {
644− notifScope.launch { container.settingsStore.setNotifError(!notifError) }
861+ if (rowVisible(
862+ settingsQuery,
863+ notificationsHeaderMatches,
864+ notificationsAnyRowMatches,
865+ notifPermissionLabel, notifPermissionDesc,
866+ )
867+ ) {
868+ val notifScope = rememberCoroutineScope()
869+ Row(
870+ modifier = Modifier
871+ .fillMaxWidth()
872+ .defaultMinSize(minHeight = 48.dp)
873+ .clickable(role = Role.Switch) {
874+ notifScope.launch { container.settingsStore.setNotifPermission(!notifPermission) }
875+ }
876+ .padding(vertical = 8.dp),
877+ verticalAlignment = Alignment.CenterVertically,
878+ ) {
879+ Column(modifier = Modifier.weight(1f)) {
880+ Text(notifPermissionLabel)
881+ Text(
882+ notifPermissionDesc,
883+ style = MaterialTheme.typography.bodySmall,
884+ color = MaterialTheme.colorScheme.onSurfaceVariant,
885+ )
886+ }
887+ Switch(checked = notifPermission, onCheckedChange = null)
645888 }
646− .padding(vertical = 8.dp),
647− verticalAlignment = Alignment.CenterVertically,
648− ) {
649− Column(modifier = Modifier.weight(1f)) {
650− Text(stringResource(R.string.settings_notif_error))
651− Text(
652− stringResource(R.string.settings_notif_error_desc),
653− style = MaterialTheme.typography.bodySmall,
654− color = MaterialTheme.colorScheme.onSurfaceVariant,
889+ }
890+ if (rowVisible(
891+ settingsQuery,
892+ notificationsHeaderMatches,
893+ notificationsAnyRowMatches,
894+ notifErrorLabel, notifErrorDesc,
655895 )
896+ ) {
897+ val notifScope = rememberCoroutineScope()
898+ Row(
899+ modifier = Modifier
900+ .fillMaxWidth()
901+ .defaultMinSize(minHeight = 48.dp)
902+ .clickable(role = Role.Switch) {
903+ notifScope.launch { container.settingsStore.setNotifError(!notifError) }
904+ }
905+ .padding(vertical = 8.dp),
906+ verticalAlignment = Alignment.CenterVertically,
907+ ) {
908+ Column(modifier = Modifier.weight(1f)) {
909+ Text(notifErrorLabel)
910+ Text(
911+ notifErrorDesc,
912+ style = MaterialTheme.typography.bodySmall,
913+ color = MaterialTheme.colorScheme.onSurfaceVariant,
914+ )
915+ }
916+ Switch(checked = notifError, onCheckedChange = null)
917+ }
656918 }
657− Switch(checked = notifError, onCheckedChange = null)
658919 }
659920
660− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
921+ if (showNotifications) {
922+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
923+ }
661924
662925 // Swipe actions: remappable left/right swipe on session list rows. Defaults
663926 // match the prior hardcoded behavior (left=delete, right=archive). NONE
664927 // disables the swipe entirely.
665− Text(
666− stringResource(R.string.settings_swipe_actions),
667− style = MaterialTheme.typography.titleSmall,
668− color = MaterialTheme.colorScheme.primary,
669− modifier = Modifier.semantics { heading() },
670− )
671− val swipeScope = rememberCoroutineScope()
672− val swipeOptions = remember {
673− listOf(
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,
928+ if (showSwipeActions) {
929+ Text(
930+ swipeActionsHeader,
931+ style = MaterialTheme.typography.titleSmall,
932+ color = MaterialTheme.colorScheme.primary,
933+ modifier = Modifier.semantics { heading() },
678934 )
679− }
680− var swipeLeftMenu by remember { mutableStateOf(false) }
681− var swipeRightMenu by remember { mutableStateOf(false) }
682− val swipeLeftParsed = remember(swipeLeftAction) {
683− runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
684− }
685− val swipeRightParsed = remember(swipeRightAction) {
686− runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
687− }
688− Row(
689− modifier = Modifier
690− .fillMaxWidth()
691− .defaultMinSize(minHeight = 48.dp)
692− .padding(vertical = 8.dp),
693− verticalAlignment = Alignment.CenterVertically,
694− ) {
695− Text(stringResource(R.string.settings_swipe_left), modifier = Modifier.weight(1f))
696− Box {
697− TextButton(onClick = { swipeLeftMenu = true }) {
698− Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
935+ val swipeScope = rememberCoroutineScope()
936+ val swipeOptions = remember {
937+ listOf(
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,
942+ )
943+ }
944+ var swipeLeftMenu by remember { mutableStateOf(false) }
945+ var swipeRightMenu by remember { mutableStateOf(false) }
946+ val swipeLeftParsed = remember(swipeLeftAction) {
947+ runCatching { SwipeAction.valueOf(swipeLeftAction) }.getOrDefault(SwipeAction.DELETE)
948+ }
949+ val swipeRightParsed = remember(swipeRightAction) {
950+ runCatching { SwipeAction.valueOf(swipeRightAction) }.getOrDefault(SwipeAction.ARCHIVE)
951+ }
952+ if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeLeftLabel)) {
953+ Row(
954+ modifier = Modifier
955+ .fillMaxWidth()
956+ .defaultMinSize(minHeight = 48.dp)
957+ .padding(vertical = 8.dp),
958+ verticalAlignment = Alignment.CenterVertically,
959+ ) {
960+ Text(swipeLeftLabel, modifier = Modifier.weight(1f))
961+ Box {
962+ TextButton(onClick = { swipeLeftMenu = true }) {
963+ Text(stringResource(swipeOptions.first { it.first == swipeLeftParsed }.second))
964+ }
965+ DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
966+ swipeOptions.forEach { (action, label) ->
967+ DropdownMenuItem(
968+ text = { Text(stringResource(label)) },
969+ onClick = {
970+ swipeLeftMenu = false
971+ swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
972+ },
973+ )
974+ }
975+ }
976+ }
699977 }
700− DropdownMenu(expanded = swipeLeftMenu, onDismissRequest = { swipeLeftMenu = false }) {
701− swipeOptions.forEach { (action, label) ->
702− DropdownMenuItem(
703− text = { Text(stringResource(label)) },
704− onClick = {
705− swipeLeftMenu = false
706− swipeScope.launch { container.settingsStore.setSwipeLeftAction(action) }
707− },
708− )
978+ }
979+ if (rowVisible(settingsQuery, swipeHeaderMatches, swipeAnyRowMatches, swipeRightLabel)) {
980+ Row(
981+ modifier = Modifier
982+ .fillMaxWidth()
983+ .defaultMinSize(minHeight = 48.dp)
984+ .padding(vertical = 8.dp),
985+ verticalAlignment = Alignment.CenterVertically,
986+ ) {
987+ Text(swipeRightLabel, modifier = Modifier.weight(1f))
988+ Box {
989+ TextButton(onClick = { swipeRightMenu = true }) {
990+ Text(stringResource(swipeOptions.first { it.first == swipeRightParsed }.second))
991+ }
992+ DropdownMenu(expanded = swipeRightMenu, onDismissRequest = { swipeRightMenu = false }) {
993+ swipeOptions.forEach { (action, label) ->
994+ DropdownMenuItem(
995+ text = { Text(stringResource(label)) },
996+ onClick = {
997+ swipeRightMenu = false
998+ swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
999+ },
1000+ )
1001+ }
1002+ }
7091003 }
7101004 }
7111005 }
7121006 }
713− Row(
714− modifier = Modifier
715− .fillMaxWidth()
716− .defaultMinSize(minHeight = 48.dp)
717− .padding(vertical = 8.dp),
718− verticalAlignment = Alignment.CenterVertically,
719− ) {
720− Text(stringResource(R.string.settings_swipe_right), modifier = Modifier.weight(1f))
721− Box {
722− TextButton(onClick = { swipeRightMenu = true }) {
723− Text(stringResource(swipeOptions.first { it.first == swipeRightParsed }.second))
1007+
1008+ if (showSwipeActions) {
1009+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1010+ }
1011+
1012+ if (showConnection) {
1013+ Text(
1014+ connectionHeader,
1015+ style = MaterialTheme.typography.titleSmall,
1016+ color = MaterialTheme.colorScheme.primary,
1017+ modifier = Modifier.semantics { heading() },
1018+ )
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))
1023+ Text(
1024+ activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
1025+ style = MaterialTheme.typography.bodySmall,
1026+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1027+ )
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
7241041 }
725− DropdownMenu(expanded = swipeRightMenu, onDismissRequest = { swipeRightMenu = false }) {
726− swipeOptions.forEach { (action, label) ->
727− DropdownMenuItem(
728− text = { Text(stringResource(label)) },
729− onClick = {
730− swipeRightMenu = false
731− swipeScope.launch { container.settingsStore.setSwipeRightAction(action) }
732− },
1042+ stateText?.let {
1043+ Row(
1044+ modifier = Modifier.padding(top = 4.dp),
1045+ verticalAlignment = Alignment.CenterVertically,
1046+ ) {
1047+ val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
1048+ connectionState == EventStreamClient.ConnectionState.AuthFailed
1049+ Text(
1050+ it,
1051+ style = MaterialTheme.typography.bodySmall,
1052+ color = if (isHardFailure)
1053+ MaterialTheme.colorScheme.error
1054+ else MaterialTheme.colorScheme.onSurfaceVariant,
7331055 )
1056+ if (isHardFailure) {
1057+ TextButton(
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)) }
1062+ }
7341063 }
7351064 }
1065+ } else {
1066+ Text(
1067+ stringResource(R.string.not_connected),
1068+ modifier = Modifier.padding(top = 8.dp),
1069+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1070+ )
1071+ }
1072+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, manageServersLabel)) {
1073+ Row(
1074+ modifier = Modifier
1075+ .fillMaxWidth()
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,
1081+ ) {
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)
1085+ }
1086+ }
1087+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, usageLabel)) {
1088+ NavRow(
1089+ icon = Icons.Filled.QueryStats,
1090+ label = usageLabel,
1091+ enabled = activeProfile != null,
1092+ onClick = onOpenUsage,
1093+ )
1094+ }
1095+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, mcpLabel)) {
1096+ NavRow(
1097+ icon = Icons.Filled.Hub,
1098+ label = mcpLabel,
1099+ enabled = activeProfile != null,
1100+ onClick = onOpenMcp,
1101+ )
1102+ }
1103+ if (rowVisible(settingsQuery, connectionHeaderMatches, connectionAnyRowMatches, notifSettingsLabel)) {
1104+ NavRow(
1105+ icon = Icons.Filled.Notifications,
1106+ label = notifSettingsLabel,
1107+ enabled = true,
1108+ onClick = {
1109+ scope.launch {
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)
1114+ }.isSuccess
1115+ if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
1116+ }
1117+ },
1118+ )
7361119 }
7371120 }
7381121
739− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1122+ if (showConnection) {
1123+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1124+ }
7401125
741− Text(
742− stringResource(R.string.connection),
743− style = MaterialTheme.typography.titleSmall,
744− color = MaterialTheme.colorScheme.primary,
745− modifier = Modifier.semantics { heading() },
746− )
747− if (activeProfile != null) {
748− Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
1126+ if (showBackup) {
7491127 Text(
750− activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
751− style = MaterialTheme.typography.bodySmall,
752− color = MaterialTheme.colorScheme.onSurfaceVariant,
1128+ backupHeader,
1129+ style = MaterialTheme.typography.titleSmall,
1130+ color = MaterialTheme.colorScheme.primary,
1131+ modifier = Modifier.semantics { heading() },
7531132 )
754− // Surface the SSE stream state so a user who opens Settings during a
755− // reconnect saw it (the ConnectionBanner on other screens handles this
756− // elsewhere). Only show non-Connected states to avoid clutter. On a hard
757− // failure offer an inline Retry — without it a user who opened Settings
758− // during a failure is stuck on an error message with no recovery path
759− // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
760− // host it). Retry forces an SSE reconnect, which re-seeds from REST.
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
1133+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, backupDescText)) {
1134+ Text(
1135+ backupDescText,
1136+ style = MaterialTheme.typography.bodySmall,
1137+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1138+ modifier = Modifier.padding(top = 4.dp),
1139+ )
7671140 }
768− stateText?.let {
1141+ if (rowVisible(
1142+ settingsQuery,
1143+ backupHeaderMatches,
1144+ backupAnyRowMatches,
1145+ backupIncludePasswordsLabel, backupIncludePasswordsDesc,
1146+ )
1147+ ) {
7691148 Row(
770− modifier = Modifier.padding(top = 4.dp),
1149+ modifier = Modifier
1150+ .fillMaxWidth()
1151+ .defaultMinSize(minHeight = 48.dp)
1152+ .toggleable(
1153+ value = includePasswords,
1154+ onValueChange = { includePasswords = it },
1155+ role = Role.Switch,
1156+ )
1157+ .padding(vertical = 8.dp),
7711158 verticalAlignment = Alignment.CenterVertically,
7721159 ) {
773− val isHardFailure = connectionState == EventStreamClient.ConnectionState.Failed ||
774− connectionState == EventStreamClient.ConnectionState.AuthFailed
775− Text(
776− it,
777− style = MaterialTheme.typography.bodySmall,
778− color = if (isHardFailure)
779− MaterialTheme.colorScheme.error
780− else MaterialTheme.colorScheme.onSurfaceVariant,
781− )
782− if (isHardFailure) {
783− TextButton(
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)) }
1160+ Column(modifier = Modifier.weight(1f)) {
1161+ Text(backupIncludePasswordsLabel, style = MaterialTheme.typography.bodyLarge)
1162+ Text(
1163+ backupIncludePasswordsDesc,
1164+ style = MaterialTheme.typography.bodySmall,
1165+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1166+ )
7881167 }
1168+ Switch(checked = includePasswords, onCheckedChange = null)
7891169 }
7901170 }
791− } else {
792− Text(
793− stringResource(R.string.not_connected),
794− modifier = Modifier.padding(top = 8.dp),
795− color = MaterialTheme.colorScheme.onSurfaceVariant,
796− )
797− }
798− Row(
799− modifier = Modifier
800− .fillMaxWidth()
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,
806− ) {
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)
1171+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, exportBackupLabel)) {
1172+ NavRow(
1173+ icon = Icons.Filled.Upload,
1174+ label = exportBackupLabel,
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.
1178+ onClick = {
1179+ if (includePasswords) showExportPasswordWarning = true
1180+ else runCatching { exportLauncher.launch(backupFilename()) }
1181+ },
1182+ )
1183+ }
1184+ if (rowVisible(settingsQuery, backupHeaderMatches, backupAnyRowMatches, importBackupLabel)) {
1185+ NavRow(
1186+ icon = Icons.Filled.Download,
1187+ label = importBackupLabel,
1188+ enabled = !backupBusy,
1189+ onClick = {
1190+ runCatching {
1191+ importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
1192+ }
1193+ },
1194+ )
1195+ }
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.
1199+ if (backupBusy) {
1200+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
1201+ }
8101202 }
811− NavRow(
812− icon = Icons.Filled.QueryStats,
813− label = stringResource(R.string.usage_title),
814− enabled = activeProfile != null,
815− onClick = onOpenUsage,
816− )
817− NavRow(
818− icon = Icons.Filled.Hub,
819− label = stringResource(R.string.mcp_servers),
820− enabled = activeProfile != null,
821− onClick = onOpenMcp,
822− )
823− NavRow(
824− icon = Icons.Filled.Notifications,
825− label = stringResource(R.string.notification_settings),
826− enabled = true,
827− onClick = {
828− scope.launch {
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)
833− }.isSuccess
834− if (!opened) snackbar.showSnackbar(couldNotOpenLinkMsg)
835− }
836− },
837− )
8381203
839− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1204+ if (showBackup) {
1205+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
1206+ }
8401207
841− Text(
842− stringResource(R.string.backup_restore),
843− style = MaterialTheme.typography.titleSmall,
844− color = MaterialTheme.colorScheme.primary,
845− modifier = Modifier.semantics { heading() },
846− )
847− Text(
848− stringResource(R.string.backup_desc),
849− style = MaterialTheme.typography.bodySmall,
850− color = MaterialTheme.colorScheme.onSurfaceVariant,
851− modifier = Modifier.padding(top = 4.dp),
852− )
853− Row(
854− modifier = Modifier
855− .fillMaxWidth()
856− .defaultMinSize(minHeight = 48.dp)
857− .toggleable(
858− value = includePasswords,
859− onValueChange = { includePasswords = it },
860− role = Role.Switch,
861− )
862− .padding(vertical = 8.dp),
863− verticalAlignment = Alignment.CenterVertically,
864− ) {
865− Column(modifier = Modifier.weight(1f)) {
866− Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
1208+ if (showAbout) {
1209+ Text(
1210+ aboutHeader,
1211+ style = MaterialTheme.typography.titleSmall,
1212+ color = MaterialTheme.colorScheme.primary,
1213+ modifier = Modifier.semantics { heading() },
1214+ )
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).
1217+ Text(
1218+ stringResource(R.string.about_version, versionName),
1219+ style = MaterialTheme.typography.bodyMedium,
1220+ modifier = Modifier.padding(top = 8.dp),
1221+ )
1222+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, aboutDescText)) {
8671223 Text(
868− stringResource(R.string.backup_include_passwords_desc),
1224+ aboutDescText,
8691225 style = MaterialTheme.typography.bodySmall,
8701226 color = MaterialTheme.colorScheme.onSurfaceVariant,
1227+ modifier = Modifier.padding(top = 4.dp),
8711228 )
8721229 }
873− Switch(checked = includePasswords, onCheckedChange = null)
874− }
875− NavRow(
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.
881− onClick = {
882− if (includePasswords) showExportPasswordWarning = true
883− else runCatching { exportLauncher.launch(backupFilename()) }
884− },
885− )
886− NavRow(
887− icon = Icons.Filled.Download,
888− label = stringResource(R.string.import_backup),
889− enabled = !backupBusy,
890− onClick = {
891− runCatching {
892− importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
893− }
894− },
895− )
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.
899− if (backupBusy) {
900− LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
901− }
902−
903− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
904−
905− Text(
906− stringResource(R.string.about),
907− style = MaterialTheme.typography.titleSmall,
908− color = MaterialTheme.colorScheme.primary,
909− modifier = Modifier.semantics { heading() },
910− )
911− Text(
912− stringResource(R.string.about_version, versionName),
913− style = MaterialTheme.typography.bodyMedium,
914− modifier = Modifier.padding(top = 8.dp),
915− )
916− Text(
917− stringResource(R.string.about_desc),
918− style = MaterialTheme.typography.bodySmall,
919− color = MaterialTheme.colorScheme.onSurfaceVariant,
920− modifier = Modifier.padding(top = 4.dp),
921− )
9221230
923− Spacer(Modifier.size(16.dp))
924− Row(
925− modifier = Modifier
926− .fillMaxWidth()
927− .defaultMinSize(minHeight = 48.dp)
928− .clickable(role = Role.Button) { onOpenDiagnostics() }
929− .padding(vertical = 8.dp),
930− verticalAlignment = Alignment.CenterVertically,
931− ) {
932− Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
933− Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
934− // 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
936− // consistency with the nav rail's badge (which already uses the real M3
937− // components) instead of a hand-rolled Box.
938− if (crashCount > 0) {
939− BadgedBox(
940− badge = {
941− Badge {
942− val overflow = stringResource(R.string.crash_count_overflow)
943− Text(if (crashCount > 99) overflow else crashCount.toString())
944− }
1231+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, diagnosticsLabel)) {
1232+ Spacer(Modifier.size(16.dp))
1233+ Row(
1234+ modifier = Modifier
1235+ .fillMaxWidth()
1236+ .defaultMinSize(minHeight = 48.dp)
1237+ .clickable(role = Role.Button) { onOpenDiagnostics() }
1238+ .padding(vertical = 8.dp),
1239+ verticalAlignment = Alignment.CenterVertically,
1240+ ) {
1241+ Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
1242+ Text(diagnosticsLabel, modifier = Modifier.weight(1f).padding(start = 8.dp))
1243+ // Badge with the crash count so the user can tell at a glance whether
1244+ // there's something worth investigating. Uses M3 BadgedBox+Badge for
1245+ // consistency with the nav rail's badge (which already uses the real M3
1246+ // components) instead of a hand-rolled Box.
1247+ if (crashCount > 0) {
1248+ BadgedBox(
1249+ badge = {
1250+ Badge {
1251+ val overflow = stringResource(R.string.crash_count_overflow)
1252+ Text(if (crashCount > 99) overflow else crashCount.toString())
1253+ }
1254+ },
1255+ ) {}
1256+ Spacer(Modifier.size(8.dp))
1257+ }
1258+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
1259+ }
1260+ }
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)) {
1265+ NavRow(
1266+ icon = Icons.Filled.Code,
1267+ label = sourceCodeLabel,
1268+ enabled = true,
1269+ onClick = {
1270+ runCatching {
1271+ context.startActivity(
1272+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
1273+ )
1274+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
9451275 },
946− ) {}
947− Spacer(Modifier.size(8.dp))
1276+ )
1277+ }
1278+ if (rowVisible(settingsQuery, aboutHeaderMatches, aboutAnyRowMatches, reportIssueLabel)) {
1279+ NavRow(
1280+ icon = Icons.Filled.Feedback,
1281+ label = reportIssueLabel,
1282+ enabled = true,
1283+ onClick = {
1284+ runCatching {
1285+ context.startActivity(
1286+ Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
1287+ )
1288+ }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
1289+ },
1290+ )
9481291 }
949− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
9501292 }
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.
954− NavRow(
955− icon = Icons.Filled.Code,
956− label = stringResource(R.string.source_code),
957− enabled = true,
958− onClick = {
959− runCatching {
960− context.startActivity(
961− Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_source_code))),
962− )
963− }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
964− },
965− )
966− NavRow(
967− icon = Icons.Filled.Feedback,
968− label = stringResource(R.string.report_issue),
969− enabled = true,
970− onClick = {
971− runCatching {
972− context.startActivity(
973− Intent(Intent.ACTION_VIEW, android.net.Uri.parse(context.getString(R.string.url_report_issue))),
974− )
975− }.onFailure { scope.launch { snackbar.showSnackbar(couldNotOpenLinkMsg) } }
976− },
977− )
9781293 }
9791294 }
9801295
@@ -1002,13 +1317,25 @@ fun SettingsScreen(
10021317 )
10031318 if (result == SnackbarResult.ActionPerformed) {
10041319 // 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
10061321 // 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.
10101322 runCatchingCancellable { container.shutdown() }
1011− android.os.Process.killProcess(android.os.Process.myPid())
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) {
1331+ intent.addFlags(
1332+ android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
1333+ android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK,
1334+ )
1335+ context.startActivity(intent)
1336+ } else {
1337+ android.os.Process.killProcess(android.os.Process.myPid())
1338+ }
10121339 }
10131340 } else {
10141341 snackbar.showSnackbar(importFailedMsg)
@@ -1209,6 +1536,34 @@ private val AmoledSwatches: List<Color> = AmoledPaletteSwatches
12091536@Composable
12101537private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
12111538
1539+/**
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.
1543+ */
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) }
1549+}
1550+
1551+/**
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.
1555+ */
1556+private fun rowVisible(
1557+ query: String,
1558+ headerMatches: Boolean,
1559+ anyRowMatches: Boolean,
1560+ vararg rowTexts: String,
1561+): Boolean {
1562+ if (query.isEmpty()) return true
1563+ if (headerMatches && !anyRowMatches) return true
1564+ return matchesQuery(query, *rowTexts)
1565+}
1566+
12121567/** Small wrapper so the three appearance settings load atomically and the UI can gate
12131568 * on a single null check instead of flashing hardcoded defaults. */
12141569private data class SettingsValues(