@@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
25import androidx.compose.material.icons.filled.BugReport
25import androidx.compose.material.icons.filled.BugReport
26import androidx.compose.material.icons.filled.Dns
26import androidx.compose.material.icons.filled.Dns
27import androidx.compose.material3.ExperimentalMaterial3Api
27import androidx.compose.material3.ExperimentalMaterial3Api
28+import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.HorizontalDivider
29import androidx.compose.material3.HorizontalDivider
29import androidx.compose.material3.Icon
30import androidx.compose.material3.Icon
30import androidx.compose.material3.IconButton
31import androidx.compose.material3.IconButton
@@ -52,6 +53,7 @@ import androidx.compose.ui.semantics.semantics
52import androidx.compose.ui.text.style.TextAlign
53import androidx.compose.ui.text.style.TextAlign
53import androidx.compose.ui.unit.dp
54import androidx.compose.ui.unit.dp
54import androidx.lifecycle.compose.collectAsStateWithLifecycle
55import androidx.lifecycle.compose.collectAsStateWithLifecycle
56+import kotlinx.coroutines.flow.combine
55import kotlinx.coroutines.launch
57import kotlinx.coroutines.launch
56import soy.iko.opencode.data.repo.CrashLogger
58import soy.iko.opencode.data.repo.CrashLogger
57import soy.iko.opencode.data.repo.ThemeMode
59import soy.iko.opencode.data.repo.ThemeMode
@@ -65,9 +67,15 @@ import soy.iko.opencode.util.runCatchingCancellable
65@Composable
67@Composable
66fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
68fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
67 val scope = rememberCoroutineScope()
69 val scope = rememberCoroutineScope()
68− val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
70+ // Combine the three settings into a single nullable state so the appearance section
71+ // renders only after the persisted values have loaded, avoiding a brief flash of
72+ // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
73+ val settings by combine(
74+ container.settingsStore.themeMode,
69− val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
75+ container.settingsStore.dynamicColor,
76+ container.settingsStore.sendOnEnter,
77+ ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
70− val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
78+ .collectAsStateWithLifecycle(initialValue = null)
71 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
79 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
72 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
80 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
73 val context = LocalContext.current
81 val context = LocalContext.current
@@ -104,75 +112,85 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
105 ) { padding ->
113 ) { padding ->
106 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
114 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
108 stringResource(R.string.appearance),
117 stringResource(R.string.appearance),
109 style = MaterialTheme.typography.titleSmall,
118 style = MaterialTheme.typography.titleSmall,
110 color = MaterialTheme.colorScheme.primary,
119 color = MaterialTheme.colorScheme.primary,
111 modifier = Modifier.semantics { heading() },
120 modifier = Modifier.semantics { heading() },
113− ThemeMode.entries.forEach { mode ->
123+ ThemeMode.entries.forEach { mode ->
114− ThemeRow(
124+ ThemeRow(
115− mode = mode,
125+ mode = mode,
116− selected = themeMode == mode,
126+ selected = s.themeMode == mode,
117− onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
127+ onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
131+ Spacer(Modifier.size(8.dp))
132+ // Dynamic color (Material You) only works on Android 12+, so hide the toggle
133+ // on older devices instead of offering a control that silently does nothing.
134+ if (dynamicColorAvailable) {
139+ value = s.dynamicColor,
140+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
143+ .padding(vertical = 8.dp),
144+ verticalAlignment = Alignment.CenterVertically,
146+ Column(modifier = Modifier.weight(1f)) {
147+ Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
149+ stringResource(R.string.dynamic_color_desc),
150+ style = MaterialTheme.typography.bodySmall,
151+ color = MaterialTheme.colorScheme.onSurfaceVariant,
155+ checked = s.dynamicColor,
156+ onCheckedChange = null,
161+ // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
121− Spacer(Modifier.size(8.dp))
162+ Spacer(Modifier.size(4.dp))
122− // Dynamic color (Material You) only works on Android 12+, so hide the toggle
123− // on older devices instead of offering a control that silently does nothing.
124− if (dynamicColorAvailable) {
126 modifier = Modifier
164 modifier = Modifier
127 .fillMaxWidth()
165 .fillMaxWidth()
128 .toggleable(
166 .toggleable(
129− value = dynamicColor,
167+ value = s.sendOnEnter,
130− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
168+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
131 role = Role.Switch,
169 role = Role.Switch,
133 .padding(vertical = 8.dp),
171 .padding(vertical = 8.dp),
134 verticalAlignment = Alignment.CenterVertically,
172 verticalAlignment = Alignment.CenterVertically,
136 Column(modifier = Modifier.weight(1f)) {
174 Column(modifier = Modifier.weight(1f)) {
137− Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
175+ Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
139− stringResource(R.string.dynamic_color_desc),
177+ stringResource(R.string.send_on_enter_desc),
140 style = MaterialTheme.typography.bodySmall,
178 style = MaterialTheme.typography.bodySmall,
141 color = MaterialTheme.colorScheme.onSurfaceVariant,
179 color = MaterialTheme.colorScheme.onSurfaceVariant,
145− checked = dynamicColor,
183+ checked = s.sendOnEnter,
146 onCheckedChange = null,
184 onCheckedChange = null,
151− // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
152− Spacer(Modifier.size(4.dp))
157− value = sendOnEnter,
158− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
161− .padding(vertical = 8.dp),
189+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
162− verticalAlignment = Alignment.CenterVertically,
190+ contentAlignment = Alignment.Center,
164− Column(modifier = Modifier.weight(1f)) {
192+ CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
165− Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
167− stringResource(R.string.send_on_enter_desc),
168− style = MaterialTheme.typography.bodySmall,
169− color = MaterialTheme.colorScheme.onSurfaceVariant,
173− checked = sendOnEnter,
174− onCheckedChange = null,
178 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
196 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
@@ -311,6 +329,14 @@ private val LightSwatches: List<Color> = LightPaletteSwatches
311@Composable
329@Composable
312private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
330private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
332+/** Small wrapper so the three appearance settings load atomically and the UI can gate
333+ * on a single null check instead of flashing hardcoded defaults. */
334+private data class SettingsValues(
335+ val themeMode: ThemeMode,
336+ val dynamicColor: Boolean,
337+ val sendOnEnter: Boolean,
314/** Small circular count badge used to indicate pending crash reports. */
340/** Small circular count badge used to indicate pending crash reports. */
315@Composable
341@Composable
316private fun Badge(count: Int) {
342private fun Badge(count: Int) {