@@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
2525import androidx.compose.material.icons.filled.BugReport
2626import androidx.compose.material.icons.filled.Dns
2727import androidx.compose.material3.ExperimentalMaterial3Api
28+import androidx.compose.material3.CircularProgressIndicator
2829import androidx.compose.material3.HorizontalDivider
2930import androidx.compose.material3.Icon
3031import androidx.compose.material3.IconButton
@@ -52,6 +53,7 @@ import androidx.compose.ui.semantics.semantics
5253import androidx.compose.ui.text.style.TextAlign
5354import androidx.compose.ui.unit.dp
5455import androidx.lifecycle.compose.collectAsStateWithLifecycle
56+import kotlinx.coroutines.flow.combine
5557import kotlinx.coroutines.launch
5658import soy.iko.opencode.data.repo.CrashLogger
5759import soy.iko.opencode.data.repo.ThemeMode
@@ -65,9 +67,15 @@ import soy.iko.opencode.util.runCatchingCancellable
6567@Composable
6668fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
6769 val scope = rememberCoroutineScope()
68− val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
69− val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
70− val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
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,
75+ container.settingsStore.dynamicColor,
76+ container.settingsStore.sendOnEnter,
77+ ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
78+ .collectAsStateWithLifecycle(initialValue = null)
7179 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
7280 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
7381 val context = LocalContext.current
@@ -104,75 +112,85 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
104112 },
105113 ) { padding ->
106114 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
115+ val s = settings
107116 Text(
108117 stringResource(R.string.appearance),
109118 style = MaterialTheme.typography.titleSmall,
110119 color = MaterialTheme.colorScheme.primary,
111120 modifier = Modifier.semantics { heading() },
112121 )
113− ThemeMode.entries.forEach { mode ->
114− ThemeRow(
115− mode = mode,
116− selected = themeMode == mode,
117− onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
118− )
119− }
122+ if (s != null) {
123+ ThemeMode.entries.forEach { mode ->
124+ ThemeRow(
125+ mode = mode,
126+ selected = s.themeMode == mode,
127+ onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
128+ )
129+ }
130+
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) {
135+ Row(
136+ modifier = Modifier
137+ .fillMaxWidth()
138+ .toggleable(
139+ value = s.dynamicColor,
140+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
141+ role = Role.Switch,
142+ )
143+ .padding(vertical = 8.dp),
144+ verticalAlignment = Alignment.CenterVertically,
145+ ) {
146+ Column(modifier = Modifier.weight(1f)) {
147+ Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
148+ Text(
149+ stringResource(R.string.dynamic_color_desc),
150+ style = MaterialTheme.typography.bodySmall,
151+ color = MaterialTheme.colorScheme.onSurfaceVariant,
152+ )
153+ }
154+ Switch(
155+ checked = s.dynamicColor,
156+ onCheckedChange = null,
157+ )
158+ }
159+ }
120160
121− Spacer(Modifier.size(8.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) {
161+ // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
162+ Spacer(Modifier.size(4.dp))
125163 Row(
126164 modifier = Modifier
127165 .fillMaxWidth()
128166 .toggleable(
129− value = dynamicColor,
130− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
167+ value = s.sendOnEnter,
168+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
131169 role = Role.Switch,
132170 )
133171 .padding(vertical = 8.dp),
134172 verticalAlignment = Alignment.CenterVertically,
135173 ) {
136174 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)
138176 Text(
139− stringResource(R.string.dynamic_color_desc),
177+ stringResource(R.string.send_on_enter_desc),
140178 style = MaterialTheme.typography.bodySmall,
141179 color = MaterialTheme.colorScheme.onSurfaceVariant,
142180 )
143181 }
144182 Switch(
145− checked = dynamicColor,
183+ checked = s.sendOnEnter,
146184 onCheckedChange = null,
147185 )
148186 }
149− }
150−
151− // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
152− Spacer(Modifier.size(4.dp))
153− Row(
154− modifier = Modifier
155− .fillMaxWidth()
156− .toggleable(
157− value = sendOnEnter,
158− onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
159− role = Role.Switch,
160− )
161− .padding(vertical = 8.dp),
162− verticalAlignment = Alignment.CenterVertically,
163− ) {
164− Column(modifier = Modifier.weight(1f)) {
165− Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
166− Text(
167− stringResource(R.string.send_on_enter_desc),
168− style = MaterialTheme.typography.bodySmall,
169− color = MaterialTheme.colorScheme.onSurfaceVariant,
170− )
187+ } else {
188+ Box(
189+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
190+ contentAlignment = Alignment.Center,
191+ ) {
192+ CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
171193 }
172− Switch(
173− checked = sendOnEnter,
174− onCheckedChange = null,
175− )
176194 }
177195
178196 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
@@ -311,6 +329,14 @@ private val LightSwatches: List<Color> = LightPaletteSwatches
311329@Composable
312330private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
313331
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,
338+)
339+
314340/** Small circular count badge used to indicate pending crash reports. */
315341@Composable
316342private fun Badge(count: Int) {