1package soy.iko.opencode.ui.settings
3import android.content.pm.PackageManager
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)
88 container: AppContainer,
90 onManageServers: () -> Unit,
91 onOpenDiagnostics: () -> Unit,
92 onOpenUsage: () -> Unit = {},
93 onOpenMcp: () -> Unit = {},
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) {
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? }
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) }
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 {
139 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
140 context.packageManager.getPackageInfo(
142 PackageManager.PackageInfoFlags.of(0),
145 @Suppress("DEPRECATION")
146 context.packageManager.getPackageInfo(context.packageName, 0).versionName
148 }.getOrNull() ?: unknownVersion
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"),
161 if (uri == null) return@rememberLauncherForActivityResult
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")
168 showToast(context, if (ok) exportedMsg else exportFailedMsg)
171 val importLauncher = rememberLauncherForActivityResult(
172 ActivityResultContracts.OpenDocument(),
174 if (uri == null) return@rememberLauncherForActivityResult
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)
181 showToast(context, if (ok) importedMsg else importFailedMsg)
188 title = { Text(stringResource(R.string.settings)) },
190 IconButton(onClick = onBack) {
191 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
197 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
200 stringResource(R.string.appearance),
201 style = MaterialTheme.typography.titleSmall,
202 color = MaterialTheme.colorScheme.primary,
203 modifier = Modifier.semantics { heading() },
206 ThemeMode.entries.forEach { mode ->
209 selected = s.themeMode == mode,
210 onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
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) {
222 value = s.dynamicColor,
223 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
226 .padding(vertical = 8.dp),
227 verticalAlignment = Alignment.CenterVertically,
229 Column(modifier = Modifier.weight(1f)) {
230 Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
232 stringResource(R.string.dynamic_color_desc),
233 style = MaterialTheme.typography.bodySmall,
234 color = MaterialTheme.colorScheme.onSurfaceVariant,
238 checked = s.dynamicColor,
239 onCheckedChange = null,
244 // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
245 Spacer(Modifier.size(4.dp))
250 value = s.sendOnEnter,
251 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
254 .padding(vertical = 8.dp),
255 verticalAlignment = Alignment.CenterVertically,
257 Column(modifier = Modifier.weight(1f)) {
258 Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
260 stringResource(R.string.send_on_enter_desc),
261 style = MaterialTheme.typography.bodySmall,
262 color = MaterialTheme.colorScheme.onSurfaceVariant,
266 checked = s.sendOnEnter,
267 onCheckedChange = null,
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))
282 enabled = appLockToggleable,
283 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
286 .padding(vertical = 8.dp),
287 verticalAlignment = Alignment.CenterVertically,
289 Column(modifier = Modifier.weight(1f)) {
290 Text(stringResource(R.string.app_lock), style = MaterialTheme.typography.bodyLarge)
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,
299 onCheckedChange = null,
300 enabled = appLockToggleable,
305 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
306 contentAlignment = Alignment.Center,
308 CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
312 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
315 stringResource(R.string.text_display),
316 style = MaterialTheme.typography.titleSmall,
317 color = MaterialTheme.colorScheme.primary,
318 modifier = Modifier.semantics { heading() },
321 scale = chatTextScale,
322 onScaleChange = { scope.launch { runCatchingCancellable { container.settingsStore.setChatTextScale(it) } } },
324 Spacer(Modifier.size(4.dp))
330 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setCodeWrap(it) } } },
333 .padding(vertical = 8.dp),
334 verticalAlignment = Alignment.CenterVertically,
336 Column(modifier = Modifier.weight(1f)) {
337 Text(stringResource(R.string.wrap_code_blocks), style = MaterialTheme.typography.bodyLarge)
339 stringResource(R.string.wrap_code_blocks_desc),
340 style = MaterialTheme.typography.bodySmall,
341 color = MaterialTheme.colorScheme.onSurfaceVariant,
344 Switch(checked = codeWrap, onCheckedChange = null)
347 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
350 stringResource(R.string.connection),
351 style = MaterialTheme.typography.titleSmall,
352 color = MaterialTheme.colorScheme.primary,
353 modifier = Modifier.semantics { heading() },
355 if (activeProfile != null) {
356 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
358 activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
359 style = MaterialTheme.typography.bodySmall,
360 color = MaterialTheme.colorScheme.onSurfaceVariant,
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
377 modifier = Modifier.padding(top = 4.dp),
378 verticalAlignment = Alignment.CenterVertically,
382 style = MaterialTheme.typography.bodySmall,
383 color = if (connectionState == EventStreamClient.ConnectionState.Failed)
384 MaterialTheme.colorScheme.error
385 else MaterialTheme.colorScheme.onSurfaceVariant,
387 if (connectionState == EventStreamClient.ConnectionState.Failed) {
389 onClick = { container.activeConnection.value?.events?.triggerReconnect() },
390 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
391 ) { Text(stringResource(R.string.retry_now)) }
397 stringResource(R.string.not_connected),
398 modifier = Modifier.padding(top = 8.dp),
399 color = MaterialTheme.colorScheme.onSurfaceVariant,
405 .defaultMinSize(minHeight = 48.dp)
407 .clickable(role = Role.Button) { onManageServers() }
408 .padding(vertical = 8.dp),
409 verticalAlignment = Alignment.CenterVertically,
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)
416 icon = Icons.Filled.QueryStats,
417 label = stringResource(R.string.usage_title),
418 enabled = activeProfile != null,
419 onClick = onOpenUsage,
422 icon = Icons.Filled.Hub,
423 label = stringResource(R.string.mcp_servers),
424 enabled = activeProfile != null,
428 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
431 stringResource(R.string.backup_restore),
432 style = MaterialTheme.typography.titleSmall,
433 color = MaterialTheme.colorScheme.primary,
434 modifier = Modifier.semantics { heading() },
437 stringResource(R.string.backup_desc),
438 style = MaterialTheme.typography.bodySmall,
439 color = MaterialTheme.colorScheme.onSurfaceVariant,
440 modifier = Modifier.padding(top = 4.dp),
446 value = includePasswords,
447 onValueChange = { includePasswords = it },
450 .padding(vertical = 8.dp),
451 verticalAlignment = Alignment.CenterVertically,
453 Column(modifier = Modifier.weight(1f)) {
454 Text(stringResource(R.string.backup_include_passwords), style = MaterialTheme.typography.bodyLarge)
456 stringResource(R.string.backup_include_passwords_desc),
457 style = MaterialTheme.typography.bodySmall,
458 color = MaterialTheme.colorScheme.onSurfaceVariant,
461 Switch(checked = includePasswords, onCheckedChange = null)
464 icon = Icons.Filled.Upload,
465 label = stringResource(R.string.export_backup),
467 onClick = { runCatching { exportLauncher.launch("opencode-backup.json") } },
470 icon = Icons.Filled.Download,
471 label = stringResource(R.string.import_backup),
475 importLauncher.launch(arrayOf("application/json", "text/plain", "application/octet-stream"))
480 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
483 stringResource(R.string.about),
484 style = MaterialTheme.typography.titleSmall,
485 color = MaterialTheme.colorScheme.primary,
486 modifier = Modifier.semantics { heading() },
489 stringResource(R.string.about_version, versionName),
490 style = MaterialTheme.typography.bodyMedium,
491 modifier = Modifier.padding(top = 8.dp),
494 stringResource(R.string.about_desc),
495 style = MaterialTheme.typography.bodySmall,
496 color = MaterialTheme.colorScheme.onSurfaceVariant,
497 modifier = Modifier.padding(top = 4.dp),
500 Spacer(Modifier.size(16.dp))
504 .defaultMinSize(minHeight = 48.dp)
505 .clickable(role = Role.Button) { onOpenDiagnostics() }
506 .padding(vertical = 8.dp),
507 verticalAlignment = Alignment.CenterVertically,
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))
517 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
523/** A clickable settings row: leading icon, label, trailing chevron, with a disabled state. */
526 icon: androidx.compose.ui.graphics.vector.ImageVector,
534 .defaultMinSize(minHeight = 48.dp)
535 .clickable(enabled = enabled, role = Role.Button) { onClick() }
536 .padding(vertical = 8.dp),
537 verticalAlignment = Alignment.CenterVertically,
541 contentDescription = null,
542 tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
546 modifier = Modifier.weight(1f).padding(start = 8.dp),
547 color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
550 Icons.AutoMirrored.Filled.KeyboardArrowRight,
551 contentDescription = null,
552 tint = MaterialTheme.colorScheme.onSurfaceVariant,
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. */
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
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),
572 stringResource(R.string.chat_text_size_desc),
573 style = MaterialTheme.typography.bodySmall,
574 color = MaterialTheme.colorScheme.onSurfaceVariant,
576 Row(verticalAlignment = Alignment.CenterVertically) {
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),
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),
593 sliderValue = SettingsStore.DEFAULT_CHAT_TEXT_SCALE
594 onScaleChange(SettingsStore.DEFAULT_CHAT_TEXT_SCALE)
596 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
597 ) { Text(stringResource(R.string.reset)) }
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
610 ThemeMode.LIGHT -> LightSwatches
611 ThemeMode.DARK -> DarkSwatches
616 .selectable(selected = selected, onClick = onSelect)
617 .padding(vertical = 8.dp),
618 horizontalArrangement = Arrangement.spacedBy(8.dp),
619 verticalAlignment = Alignment.CenterVertically,
621 RadioButton(selected = selected, onClick = null)
624 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
625 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
626 ThemeMode.DARK -> stringResource(R.string.theme_dark)
628 style = MaterialTheme.typography.bodyLarge,
629 modifier = Modifier.weight(1f),
631 // Three small color dots previewing the palette for this mode.
632 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
633 swatches.forEach { color ->
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.
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. */
668private fun Badge(count: Int) {
669 val overflow = stringResource(R.string.crash_count_overflow)
674 .background(MaterialTheme.colorScheme.error),
675 contentAlignment = Alignment.Center,
678 if (count > 99) overflow else count.toString(),
679 style = MaterialTheme.typography.labelSmall,
680 color = MaterialTheme.colorScheme.onError,
681 textAlign = TextAlign.Center,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list