git.iko.soy/opencode-android
on 0a26f8d help sign in
684 lines 32 kB history raw
1package soy.iko.opencode.ui.settings
3import android.content.pm.PackageManager
4import android.os.Build
5import androidx.activity.compose.rememberLauncherForActivityResult
6import androidx.activity.result.contract.ActivityResultContracts
7import androidx.compose.foundation.background
8import androidx.compose.foundation.clickable
9import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Box
11import androidx.compose.foundation.layout.Column
12import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.Spacer
14import androidx.compose.foundation.layout.defaultMinSize
15import androidx.compose.foundation.layout.fillMaxWidth
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.size
18import androidx.compose.foundation.layout.widthIn
19import androidx.compose.foundation.rememberScrollState
20import androidx.compose.foundation.selection.selectable
21import androidx.compose.foundation.selection.toggleable
22import androidx.compose.foundation.verticalScroll
23import androidx.compose.foundation.shape.CircleShape
24import androidx.compose.material.icons.Icons
25import androidx.compose.material.icons.automirrored.filled.ArrowBack
26import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
27import androidx.compose.material.icons.filled.BugReport
28import androidx.compose.material.icons.filled.Dns
29import androidx.compose.material.icons.filled.Download
30import androidx.compose.material.icons.filled.Hub
31import androidx.compose.material.icons.filled.QueryStats
32import androidx.compose.material.icons.filled.Upload
33import androidx.compose.material3.ExperimentalMaterial3Api
34import androidx.compose.material3.CircularProgressIndicator
35import androidx.compose.material3.HorizontalDivider
36import androidx.compose.material3.Icon
37import androidx.compose.material3.IconButton
38import androidx.compose.material3.MaterialTheme
39import androidx.compose.material3.RadioButton
40import androidx.compose.material3.Scaffold
41import androidx.compose.material3.Slider
42import androidx.compose.material3.Switch
43import androidx.compose.material3.Text
44import androidx.compose.material3.TextButton
45import androidx.compose.material3.TopAppBar
46import androidx.compose.runtime.Composable
47import androidx.compose.runtime.getValue
48import androidx.compose.runtime.mutableFloatStateOf
49import androidx.compose.runtime.mutableStateOf
50import androidx.compose.runtime.remember
51import androidx.compose.runtime.rememberCoroutineScope
52import androidx.compose.runtime.saveable.rememberSaveable
53import androidx.compose.runtime.setValue
54import androidx.compose.ui.Alignment
55import androidx.compose.ui.Modifier
56import androidx.compose.ui.draw.clip
57import androidx.compose.ui.graphics.Color
58import androidx.compose.ui.platform.LocalContext
59import androidx.compose.ui.res.stringResource
60import androidx.compose.ui.semantics.Role
61import androidx.compose.ui.semantics.heading
62import androidx.compose.ui.semantics.role
63import androidx.compose.ui.semantics.semantics
64import androidx.compose.ui.text.style.TextAlign
65import androidx.compose.ui.unit.dp
66import androidx.lifecycle.compose.collectAsStateWithLifecycle
67import kotlinx.coroutines.ExperimentalCoroutinesApi
68import kotlinx.coroutines.flow.combine
69import kotlinx.coroutines.flow.flatMapLatest
70import kotlinx.coroutines.flow.flowOf
71import kotlinx.coroutines.launch
72import soy.iko.opencode.data.network.EventStreamClient
73import soy.iko.opencode.data.repo.CrashLogger
74import soy.iko.opencode.data.repo.SettingsStore
75import soy.iko.opencode.data.repo.ThemeMode
76import soy.iko.opencode.di.AppContainer
77import soy.iko.opencode.R
78import soy.iko.opencode.ui.canAuthenticateForAppLock
79import soy.iko.opencode.ui.components.showToast
80import soy.iko.opencode.ui.theme.LightPaletteSwatches
81import soy.iko.opencode.ui.theme.DarkPaletteSwatches
82import soy.iko.opencode.util.runCatchingCancellable
83import kotlin.math.roundToInt
85@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
86@Composable
87fun SettingsScreen(
88 container: AppContainer,
89 onBack: () -> Unit,
90 onManageServers: () -> Unit,
91 onOpenDiagnostics: () -> Unit,
92 onOpenUsage: () -> Unit = {},
93 onOpenMcp: () -> Unit = {},
94) {
95 val scope = rememberCoroutineScope()
96 // Combine the three settings into a single nullable state so the appearance section
97 // renders only after the persisted values have loaded, avoiding a brief flash of
98 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
99 // Wrapped in remember so the combined Flow isn't re-created on every recomposition
100 // (which would cancel and relaunch the DataStore collection each time — see the
101 // connectionStateFlow below, remembered for the same reason).
102 val settingsFlow = remember(container) {
103 combine(
104 container.settingsStore.themeMode,
105 container.settingsStore.dynamicColor,
106 container.settingsStore.sendOnEnter,
107 container.settingsStore.appLock,
108 ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
109 }
110 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
111 // Text/display prefs are collected separately from [settingsFlow] (kotlinx combine
112 // tops out at 5 typed flows) and render independently — they don't need the same
113 // load-gate as the appearance block.
114 val chatTextScale by container.settingsStore.chatTextScale
115 .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
116 val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
117 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
118 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
119 // SSE connection state so the Settings screen can show a dropped stream (the
120 // ConnectionBanner surfaces this on the chat and session screens; without it here,
121 // a user who opens Settings during a reconnect wouldn't know the stream dropped).
122 // Wrapped in remember so the Flow operator isn't re-created every recomposition
123 // (which would reset collectAsStateWithLifecycle and cause flicker).
124 val connectionStateFlow = remember {
125 container.activeConnection
126 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
127 }
128 val connectionState by connectionStateFlow
129 .collectAsStateWithLifecycle(initialValue = EventStreamClient.ConnectionState.Disconnected)
130 val context = LocalContext.current
131 // Crash count badge: surface that there are reports to look at without making the
132 // user open the screen to find out.
133 val crashLogger = remember { CrashLogger.get(context) }
134 val crashReports by crashLogger.reports.collectAsStateWithLifecycle()
135 val crashCount = crashReports.size
136 val unknownVersion = stringResource(R.string.unknown_version)
137 val versionName = remember {
138 runCatching {
139 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
140 context.packageManager.getPackageInfo(
141 context.packageName,
142 PackageManager.PackageInfoFlags.of(0),
143 ).versionName
144 } else {
145 @Suppress("DEPRECATION")
146 context.packageManager.getPackageInfo(context.packageName, 0).versionName
147 }
148 }.getOrNull() ?: unknownVersion
149 }
151 // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
152 // import reads a chosen file and applies it. Feedback is a toast (this screen has no snackbar).
153 var includePasswords by rememberSaveable { mutableStateOf(false) }
154 val exportedMsg = stringResource(R.string.backup_exported)
155 val exportFailedMsg = stringResource(R.string.backup_export_failed)
156 val importedMsg = stringResource(R.string.backup_imported)
157 val importFailedMsg = stringResource(R.string.backup_import_failed)
158 val exportLauncher = rememberLauncherForActivityResult(
159 ActivityResultContracts.CreateDocument("application/json"),
160 ) { uri ->
161 if (uri == null) return@rememberLauncherForActivityResult
162 scope.launch {
163 val ok = runCatchingCancellable {
164 val text = container.backupManager.export(includePasswords)
165 context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
166 ?: error("no output stream")
167 }.isSuccess
168 showToast(context, if (ok) exportedMsg else exportFailedMsg)
169 }
170 }
171 val importLauncher = rememberLauncherForActivityResult(
172 ActivityResultContracts.OpenDocument(),
173 ) { uri ->
174 if (uri == null) return@rememberLauncherForActivityResult
175 scope.launch {
176 val ok = runCatchingCancellable {
177 val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
178 ?: error("no input stream")
179 container.backupManager.import(text)
180 }.isSuccess
181 showToast(context, if (ok) importedMsg else importFailedMsg)
182 }
183 }
185 Scaffold(
186 topBar = {
187 TopAppBar(
188 title = { Text(stringResource(R.string.settings)) },
189 navigationIcon = {
190 IconButton(onClick = onBack) {
191 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
192 }
193 },
194 )
195 },
196 ) { padding ->
197 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
198 val s = settings
199 Text(
200 stringResource(R.string.appearance),
201 style = MaterialTheme.typography.titleSmall,
202 color = MaterialTheme.colorScheme.primary,
203 modifier = Modifier.semantics { heading() },
204 )
205 if (s != null) {
206 ThemeMode.entries.forEach { mode ->
207 ThemeRow(
208 mode = mode,
209 selected = s.themeMode == mode,
210 onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
211 )
212 }
214 Spacer(Modifier.size(8.dp))
215 // Dynamic color (Material You) only works on Android 12+, so hide the toggle
216 // on older devices instead of offering a control that silently does nothing.
217 if (dynamicColorAvailable) {
218 Row(
219 modifier = Modifier
220 .fillMaxWidth()
221 .toggleable(
222 value = s.dynamicColor,
223 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
224 role = Role.Switch,
225 )
226 .padding(vertical = 8.dp),
227 verticalAlignment = Alignment.CenterVertically,
228 ) {
229 Column(modifier = Modifier.weight(1f)) {
230 Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
231 Text(
232 stringResource(R.string.dynamic_color_desc),
233 style = MaterialTheme.typography.bodySmall,
234 color = MaterialTheme.colorScheme.onSurfaceVariant,
235 )
236 }
237 Switch(
238 checked = s.dynamicColor,
239 onCheckedChange = null,
240 )
241 }
242 }
244 // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
245 Spacer(Modifier.size(4.dp))
246 Row(
247 modifier = Modifier
248 .fillMaxWidth()
249 .toggleable(
250 value = s.sendOnEnter,
251 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
252 role = Role.Switch,
253 )
254 .padding(vertical = 8.dp),
255 verticalAlignment = Alignment.CenterVertically,
256 ) {
257 Column(modifier = Modifier.weight(1f)) {
258 Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
259 Text(
260 stringResource(R.string.send_on_enter_desc),
261 style = MaterialTheme.typography.bodySmall,
262 color = MaterialTheme.colorScheme.onSurfaceVariant,
263 )
264 }
265 Switch(
266 checked = s.sendOnEnter,
267 onCheckedChange = null,
268 )
269 }
271 // App lock: gate on device capability. Always allow turning it *off* (in case
272 // the enrolled biometric was later removed), but only allow turning it on when
273 // a biometric or device credential is actually available.
274 val appLockAvailable = remember { canAuthenticateForAppLock(context) }
275 val appLockToggleable = appLockAvailable || s.appLock
276 Spacer(Modifier.size(4.dp))
277 Row(
278 modifier = Modifier
279 .fillMaxWidth()
280 .toggleable(
281 value = s.appLock,
282 enabled = appLockToggleable,
283 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
284 role = Role.Switch,
285 )
286 .padding(vertical = 8.dp),
287 verticalAlignment = Alignment.CenterVertically,
288 ) {
289 Column(modifier = Modifier.weight(1f)) {
290 Text(stringResource(R.string.app_lock), style = MaterialTheme.typography.bodyLarge)
291 Text(
292 stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
293 style = MaterialTheme.typography.bodySmall,
294 color = MaterialTheme.colorScheme.onSurfaceVariant,
295 )
296 }
297 Switch(
298 checked = s.appLock,
299 onCheckedChange = null,
300 enabled = appLockToggleable,
301 )
302 }
303 } else {
304 Box(
305 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
306 contentAlignment = Alignment.Center,
307 ) {
308 CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
309 }
310 }
312 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
314 Text(
315 stringResource(R.string.text_display),
316 style = MaterialTheme.typography.titleSmall,
317 color = MaterialTheme.colorScheme.primary,
318 modifier = Modifier.semantics { heading() },
319 )
320 ChatTextSizeControl(
321 scale = chatTextScale,
322 onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
323 )
324 Spacer(Modifier.size(4.dp))
325 Row(
326 modifier = Modifier
327 .fillMaxWidth()
328 .toggleable(
329 value = codeWrap,
330 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
331 role = Role.Switch,
332 )
333 .padding(vertical = 8.dp),
334 verticalAlignment = Alignment.CenterVertically,
335 ) {
336 Column(modifier = Modifier.weight(1f)) {
337 Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
338 Text(
339 stringResource(R.string.wrap_code_blocks_desc),
340 style = MaterialTheme.typography.bodySmall,
341 color = MaterialTheme.colorScheme.onSurfaceVariant,
342 )
343 }
344 Switch(checked = codeWrap, onCheckedChange = null)
345 }
347 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
349 Text(
350 stringResource(R.string.connection),
351 style = MaterialTheme.typography.titleSmall,
352 color = MaterialTheme.colorScheme.primary,
353 modifier = Modifier.semantics { heading() },
354 )
355 if (activeProfile != null) {
356 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
357 Text(
358 activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
359 style = MaterialTheme.typography.bodySmall,
360 color = MaterialTheme.colorScheme.onSurfaceVariant,
361 )
362 // Surface the SSE stream state so a user who opens Settings during a
363 // reconnect saw it (the ConnectionBanner on other screens handles this
364 // elsewhere). Only show non-Connected states to avoid clutter. On a hard
365 // failure offer an inline Retry — without it a user who opened Settings
366 // during a failure is stuck on an error message with no recovery path
367 // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
368 // host it). Retry forces an SSE reconnect, which re-seeds from REST.
369 val stateText = when (connectionState) {
370 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
371 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
372 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
373 EventStreamClient.ConnectionState.Connected -> null
374 }
375 stateText?.let {
376 Row(
377 modifier = Modifier.padding(top = 4.dp),
378 verticalAlignment = Alignment.CenterVertically,
379 ) {
380 Text(
381 it,
382 style = MaterialTheme.typography.bodySmall,
383 color = if (connectionState == EventStreamClient.ConnectionState.Failed)
384 MaterialTheme.colorScheme.error
385 else MaterialTheme.colorScheme.onSurfaceVariant,
386 )
387 if (connectionState == EventStreamClient.ConnectionState.Failed) {
388 TextButton(
389 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
390 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
391 ) { Text(stringResource(R.string.retry_now)) }
392 }
393 }
394 }
395 } else {
396 Text(
397 stringResource(R.string.not_connected),
398 modifier = Modifier.padding(top = 8.dp),
399 color = MaterialTheme.colorScheme.onSurfaceVariant,
400 )
401 }
402 Row(
403 modifier = Modifier
404 .fillMaxWidth()
405 .defaultMinSize(minHeight = 48.dp)
406 .padding(top = 8.dp)
407 .clickable(role = Role.Button) { onManageServers() }
408 .padding(vertical = 8.dp),
409 verticalAlignment = Alignment.CenterVertically,
410 ) {
411 Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
412 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
413 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
414 }
415 NavRow(
416 icon = Icons.Filled.QueryStats,
417 label = stringResource(R.string.usage_title),
418 enabled = activeProfile != null,
419 onClick = onOpenUsage,
420 )
421 NavRow(
422 icon = Icons.Filled.Hub,
423 label = stringResource(R.string.mcp_servers),
424 enabled = activeProfile != null,
425 onClick = onOpenMcp,
426 )
428 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
430 Text(
431 stringResource(R.string.backup_restore),
432 style = MaterialTheme.typography.titleSmall,
433 color = MaterialTheme.colorScheme.primary,
434 modifier = Modifier.semantics { heading() },
435 )
436 Text(
437 stringResource(R.string.backup_desc),
438 style = MaterialTheme.typography.bodySmall,
439 color = MaterialTheme.colorScheme.onSurfaceVariant,
440 modifier = Modifier.padding(top = 4.dp),
441 )
442 Row(
443 modifier = Modifier
444 .fillMaxWidth()
445 .toggleable(
446 value = includePasswords,
447 onValueChange = { includePasswords = it },
448 role = Role.Switch,
449 )
450 .padding(vertical = 8.dp),
451 verticalAlignment = Alignment.CenterVertically,
452 ) {
453 Column(modifier = Modifier.weight(1f)) {
454 Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
455 Text(
456 stringResource(R.string.backup_include_passwords_desc),
457 style = MaterialTheme.typography.bodySmall,
458 color = MaterialTheme.colorScheme.onSurfaceVariant,
459 )
460 }
461 Switch(checked = includePasswords, onCheckedChange = null)
462 }
463 NavRow(
464 icon = Icons.Filled.Upload,
465 label = stringResource(R.string.export_backup),
466 enabled = true,
467 onClick = { runCatching { exportLauncher.launch("opencode-backup.json") } },
468 )
469 NavRow(
470 icon = Icons.Filled.Download,
471 label = stringResource(R.string.import_backup),
472 enabled = true,
473 onClick = {
474 runCatching {
475 importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
476 }
477 },
478 )
480 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
482 Text(
483 stringResource(R.string.about),
484 style = MaterialTheme.typography.titleSmall,
485 color = MaterialTheme.colorScheme.primary,
486 modifier = Modifier.semantics { heading() },
487 )
488 Text(
489 stringResource(R.string.about_version, versionName),
490 style = MaterialTheme.typography.bodyMedium,
491 modifier = Modifier.padding(top = 8.dp),
492 )
493 Text(
494 stringResource(R.string.about_desc),
495 style = MaterialTheme.typography.bodySmall,
496 color = MaterialTheme.colorScheme.onSurfaceVariant,
497 modifier = Modifier.padding(top = 4.dp),
498 )
500 Spacer(Modifier.size(16.dp))
501 Row(
502 modifier = Modifier
503 .fillMaxWidth()
504 .defaultMinSize(minHeight = 48.dp)
505 .clickable(role = Role.Button) { onOpenDiagnostics() }
506 .padding(vertical = 8.dp),
507 verticalAlignment = Alignment.CenterVertically,
508 ) {
509 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
510 Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
511 // Badge with the crash count so the user can tell at a glance whether
512 // there's something worth investigating.
513 if (crashCount > 0) {
514 Badge(count = crashCount)
515 Spacer(Modifier.size(8.dp))
516 }
517 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
518 }
519 }
520 }
523/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
524@Composable
525private fun NavRow(
526 icon: androidx.compose.ui.graphics.vector.ImageVector,
527 label: String,
528 enabled: Boolean,
529 onClick: () -> Unit,
530) {
531 Row(
532 modifier = Modifier
533 .fillMaxWidth()
534 .defaultMinSize(minHeight = 48.dp)
535 .clickable(enabled = enabled, role = Role.Button) { onClick() }
536 .padding(vertical = 8.dp),
537 verticalAlignment = Alignment.CenterVertically,
538 ) {
539 Icon(
540 icon,
541 contentDescription = null,
542 tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
543 )
544 Text(
545 label,
546 modifier = Modifier.weight(1f).padding(start = 8.dp),
547 color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
548 )
549 Icon(
550 Icons.AutoMirrored.Filled.KeyboardArrowRight,
551 contentDescription = null,
552 tint = MaterialTheme.colorScheme.onSurfaceVariant,
553 )
554 }
557/** Chat text-size slider with a live sample line, a percentage readout, and a Reset.
558 * Drives [SettingsStore.chatTextScale]; persists only when the drag settles so a DataStore
559 * write doesn't fire on every pixel of movement. */
560@Composable
561private fun ChatTextSizeControl(scale: Float, onScaleChange: (Float) -> Unit) {
562 // Local slider state so dragging stays smooth; re-seed when the persisted value changes
563 // (e.g. Reset here, or an external write) so the control stays in sync.
564 var sliderValue by remember(scale) { mutableFloatStateOf(scale) }
565 val body = MaterialTheme.typography.bodyLarge
566 Text(
567 stringResource(R.string.text_size_sample),
568 style = body.copy(fontSize = body.fontSize * sliderValue, lineHeight = body.lineHeight * sliderValue),
569 modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
570 )
571 Text(
572 stringResource(R.string.chat_text_size_desc),
573 style = MaterialTheme.typography.bodySmall,
574 color = MaterialTheme.colorScheme.onSurfaceVariant,
575 )
576 Row(verticalAlignment = Alignment.CenterVertically) {
577 Slider(
578 value = sliderValue,
579 onValueChange = { sliderValue = it },
580 onValueChangeFinished = { onScaleChange(sliderValue) },
581 valueRange = SettingsStore.MIN_CHAT_TEXT_SCALE..SettingsStore.MAX_CHAT_TEXT_SCALE,
582 modifier = Modifier.weight(1f),
583 )
584 Text(
585 stringResource(R.string.text_scale_value, (sliderValue * 100).roundToInt()),
586 style = MaterialTheme.typography.labelLarge,
587 textAlign = TextAlign.End,
588 modifier = Modifier.widthIn(min = 44.dp).padding(start = 8.dp),
589 )
590 }
591 TextButton(
592 onClick = {
593 sliderValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE
594 onScaleChange(SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
595 },
596 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
597 ) { Text(stringResource(R.string.reset)) }
600@Composable
601private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
602 // Preview swatches so the user can see what each mode looks like before selecting,
603 // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
604 // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
605 val swatches: List<Color> = when (mode) {
606 ThemeMode.SYSTEM -> {
607 val dark = isSystemInDarkThemeStatic()
608 if (dark) DarkSwatches else LightSwatches
609 }
610 ThemeMode.LIGHT -> LightSwatches
611 ThemeMode.DARK -> DarkSwatches
612 }
613 Row(
614 modifier = Modifier
615 .fillMaxWidth()
616 .selectable(selected = selected, onClick = onSelect)
617 .padding(vertical = 8.dp),
618 horizontalArrangement = Arrangement.spacedBy(8.dp),
619 verticalAlignment = Alignment.CenterVertically,
620 ) {
621 RadioButton(selected = selected, onClick = null)
622 Text(
623 when (mode) {
624 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
625 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
626 ThemeMode.DARK -> stringResource(R.string.theme_dark)
627 },
628 style = MaterialTheme.typography.bodyLarge,
629 modifier = Modifier.weight(1f),
630 )
631 // Three small color dots previewing the palette for this mode.
632 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
633 swatches.forEach { color ->
634 Box(
635 modifier = Modifier
636 .size(14.dp)
637 .clip(CircleShape)
638 .background(color),
639 )
640 }
641 }
642 }
645// Palette preview dots for the theme rows. Primary/secondary/tertiary/background convey
646// the character of each scheme at a glance without rendering a full sample surface.
647private val DarkSwatches: List<Color> = DarkPaletteSwatches
648private val LightSwatches: List<Color> = LightPaletteSwatches
650// Composable-side read of the system dark setting for the "System" theme row preview.
651// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
652// helper; hoist it here so the ThemeRow stays a plain @Composable with no preview-time
653// recomposition surprises.
654@Composable
655private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
657/** Small wrapper so the three appearance settings load atomically and the UI can gate
658 * on a single null check instead of flashing hardcoded defaults. */
659private data class SettingsValues(
660 val themeMode: ThemeMode,
661 val dynamicColor: Boolean,
662 val sendOnEnter: Boolean,
663 val appLock: Boolean,
666/** Small circular count badge used to indicate pending crash reports. */
667@Composable
668private fun Badge(count: Int) {
669 val overflow = stringResource(R.string.crash_count_overflow)
670 Box(
671 modifier = Modifier
672 .size(20.dp)
673 .clip(CircleShape)
674 .background(MaterialTheme.colorScheme.error),
675 contentAlignment = Alignment.Center,
676 ) {
677 Text(
678 if (count > 99) overflow else count.toString(),
679 style = MaterialTheme.typography.labelSmall,
680 color = MaterialTheme.colorScheme.onError,
681 textAlign = TextAlign.Center,
682 )
683 }