git.iko.soy/opencode-android
on 8bc2cce help sign in
397 lines 19 kB history raw
1package soy.iko.opencode.ui.settings
3import android.content.pm.PackageManager
4import android.os.Build
5import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.defaultMinSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.rememberScrollState
18import androidx.compose.foundation.selection.selectable
19import androidx.compose.foundation.selection.toggleable
20import androidx.compose.foundation.verticalScroll
21import androidx.compose.foundation.shape.CircleShape
22import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.automirrored.filled.ArrowBack
24import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
25import androidx.compose.material.icons.filled.BugReport
26import androidx.compose.material.icons.filled.Dns
27import androidx.compose.material3.ExperimentalMaterial3Api
28import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.HorizontalDivider
30import androidx.compose.material3.Icon
31import androidx.compose.material3.IconButton
32import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.RadioButton
34import androidx.compose.material3.Scaffold
35import androidx.compose.material3.Switch
36import androidx.compose.material3.Text
37import androidx.compose.material3.TextButton
38import androidx.compose.material3.TopAppBar
39import androidx.compose.runtime.Composable
40import androidx.compose.runtime.getValue
41import androidx.compose.runtime.remember
42import androidx.compose.runtime.rememberCoroutineScope
43import androidx.compose.ui.Alignment
44import androidx.compose.ui.Modifier
45import androidx.compose.ui.draw.clip
46import androidx.compose.ui.graphics.Color
47import androidx.compose.ui.platform.LocalContext
48import androidx.compose.ui.res.stringResource
49import androidx.compose.ui.semantics.Role
50import androidx.compose.ui.semantics.heading
51import androidx.compose.ui.semantics.role
52import androidx.compose.ui.semantics.semantics
53import androidx.compose.ui.text.style.TextAlign
54import androidx.compose.ui.unit.dp
55import androidx.lifecycle.compose.collectAsStateWithLifecycle
56import kotlinx.coroutines.ExperimentalCoroutinesApi
57import kotlinx.coroutines.flow.combine
58import kotlinx.coroutines.flow.flatMapLatest
59import kotlinx.coroutines.flow.flowOf
60import kotlinx.coroutines.launch
61import soy.iko.opencode.data.network.EventStreamClient
62import soy.iko.opencode.data.repo.CrashLogger
63import soy.iko.opencode.data.repo.ThemeMode
64import soy.iko.opencode.di.AppContainer
65import soy.iko.opencode.R
66import soy.iko.opencode.ui.theme.LightPaletteSwatches
67import soy.iko.opencode.ui.theme.DarkPaletteSwatches
68import soy.iko.opencode.util.runCatchingCancellable
70@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
71@Composable
72fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
73 val scope = rememberCoroutineScope()
74 // Combine the three settings into a single nullable state so the appearance section
75 // renders only after the persisted values have loaded, avoiding a brief flash of
76 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
77 // Wrapped in remember so the combined Flow isn't re-created on every recomposition
78 // (which would cancel and relaunch the DataStore collection each time — see the
79 // connectionStateFlow below, remembered for the same reason).
80 val settingsFlow = remember(container) {
81 combine(
82 container.settingsStore.themeMode,
83 container.settingsStore.dynamicColor,
84 container.settingsStore.sendOnEnter,
85 ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
86 }
87 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
88 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
89 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
90 // SSE connection state so the Settings screen can show a dropped stream (the
91 // ConnectionBanner surfaces this on the chat and session screens; without it here,
92 // a user who opens Settings during a reconnect wouldn't know the stream dropped).
93 // Wrapped in remember so the Flow operator isn't re-created every recomposition
94 // (which would reset collectAsStateWithLifecycle and cause flicker).
95 val connectionStateFlow = remember {
96 container.activeConnection
97 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
98 }
99 val connectionState by connectionStateFlow
100 .collectAsStateWithLifecycle(initialValue = EventStreamClient.ConnectionState.Disconnected)
101 val context = LocalContext.current
102 // Crash count badge: surface that there are reports to look at without making the
103 // user open the screen to find out.
104 val crashLogger = remember { CrashLogger.get(context) }
105 val crashReports by crashLogger.reports.collectAsStateWithLifecycle()
106 val crashCount = crashReports.size
107 val unknownVersion = stringResource(R.string.unknown_version)
108 val versionName = remember {
109 runCatching {
110 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
111 context.packageManager.getPackageInfo(
112 context.packageName,
113 PackageManager.PackageInfoFlags.of(0),
114 ).versionName
115 } else {
116 @Suppress("DEPRECATION")
117 context.packageManager.getPackageInfo(context.packageName, 0).versionName
118 }
119 }.getOrNull() ?: unknownVersion
120 }
122 Scaffold(
123 topBar = {
124 TopAppBar(
125 title = { Text(stringResource(R.string.settings)) },
126 navigationIcon = {
127 IconButton(onClick = onBack) {
128 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
129 }
130 },
131 )
132 },
133 ) { padding ->
134 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
135 val s = settings
136 Text(
137 stringResource(R.string.appearance),
138 style = MaterialTheme.typography.titleSmall,
139 color = MaterialTheme.colorScheme.primary,
140 modifier = Modifier.semantics { heading() },
141 )
142 if (s != null) {
143 ThemeMode.entries.forEach { mode ->
144 ThemeRow(
145 mode = mode,
146 selected = s.themeMode == mode,
147 onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
148 )
149 }
151 Spacer(Modifier.size(8.dp))
152 // Dynamic color (Material You) only works on Android 12+, so hide the toggle
153 // on older devices instead of offering a control that silently does nothing.
154 if (dynamicColorAvailable) {
155 Row(
156 modifier = Modifier
157 .fillMaxWidth()
158 .toggleable(
159 value = s.dynamicColor,
160 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
161 role = Role.Switch,
162 )
163 .padding(vertical = 8.dp),
164 verticalAlignment = Alignment.CenterVertically,
165 ) {
166 Column(modifier = Modifier.weight(1f)) {
167 Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
168 Text(
169 stringResource(R.string.dynamic_color_desc),
170 style = MaterialTheme.typography.bodySmall,
171 color = MaterialTheme.colorScheme.onSurfaceVariant,
172 )
173 }
174 Switch(
175 checked = s.dynamicColor,
176 onCheckedChange = null,
177 )
178 }
179 }
181 // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
182 Spacer(Modifier.size(4.dp))
183 Row(
184 modifier = Modifier
185 .fillMaxWidth()
186 .toggleable(
187 value = s.sendOnEnter,
188 onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
189 role = Role.Switch,
190 )
191 .padding(vertical = 8.dp),
192 verticalAlignment = Alignment.CenterVertically,
193 ) {
194 Column(modifier = Modifier.weight(1f)) {
195 Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
196 Text(
197 stringResource(R.string.send_on_enter_desc),
198 style = MaterialTheme.typography.bodySmall,
199 color = MaterialTheme.colorScheme.onSurfaceVariant,
200 )
201 }
202 Switch(
203 checked = s.sendOnEnter,
204 onCheckedChange = null,
205 )
206 }
207 } else {
208 Box(
209 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
210 contentAlignment = Alignment.Center,
211 ) {
212 CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
213 }
214 }
216 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
218 Text(
219 stringResource(R.string.connection),
220 style = MaterialTheme.typography.titleSmall,
221 color = MaterialTheme.colorScheme.primary,
222 modifier = Modifier.semantics { heading() },
223 )
224 if (activeProfile != null) {
225 Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
226 Text(
227 activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
228 style = MaterialTheme.typography.bodySmall,
229 color = MaterialTheme.colorScheme.onSurfaceVariant,
230 )
231 // Surface the SSE stream state so a user who opens Settings during a
232 // reconnect sees it (the ConnectionBanner on other screens handles this
233 // elsewhere). Only show non-Connected states to avoid clutter.
234 val stateText = when (connectionState) {
235 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
236 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
237 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
238 EventStreamClient.ConnectionState.Connected -> null
239 }
240 stateText?.let {
241 Text(
242 it,
243 style = MaterialTheme.typography.bodySmall,
244 color = if (connectionState == EventStreamClient.ConnectionState.Failed)
245 MaterialTheme.colorScheme.error
246 else MaterialTheme.colorScheme.onSurfaceVariant,
247 modifier = Modifier.padding(top = 4.dp),
248 )
249 }
250 } else {
251 Text(
252 stringResource(R.string.not_connected),
253 modifier = Modifier.padding(top = 8.dp),
254 color = MaterialTheme.colorScheme.onSurfaceVariant,
255 )
256 }
257 Row(
258 modifier = Modifier
259 .fillMaxWidth()
260 .defaultMinSize(minHeight = 48.dp)
261 .padding(top = 8.dp)
262 .clickable(role = Role.Button) { onManageServers() }
263 .padding(vertical = 8.dp),
264 verticalAlignment = Alignment.CenterVertically,
265 ) {
266 Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
267 Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
268 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
269 }
271 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
273 Text(
274 stringResource(R.string.about),
275 style = MaterialTheme.typography.titleSmall,
276 color = MaterialTheme.colorScheme.primary,
277 modifier = Modifier.semantics { heading() },
278 )
279 Text(
280 stringResource(R.string.about_version, versionName),
281 style = MaterialTheme.typography.bodyMedium,
282 modifier = Modifier.padding(top = 8.dp),
283 )
284 Text(
285 stringResource(R.string.about_desc),
286 style = MaterialTheme.typography.bodySmall,
287 color = MaterialTheme.colorScheme.onSurfaceVariant,
288 modifier = Modifier.padding(top = 4.dp),
289 )
291 Spacer(Modifier.size(16.dp))
292 Row(
293 modifier = Modifier
294 .fillMaxWidth()
295 .defaultMinSize(minHeight = 48.dp)
296 .clickable(role = Role.Button) { onOpenDiagnostics() }
297 .padding(vertical = 8.dp),
298 verticalAlignment = Alignment.CenterVertically,
299 ) {
300 Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
301 Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
302 // Badge with the crash count so the user can tell at a glance whether
303 // there's something worth investigating.
304 if (crashCount > 0) {
305 Badge(count = crashCount)
306 Spacer(Modifier.size(8.dp))
307 }
308 Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
309 }
310 }
311 }
314@Composable
315private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
316 // Preview swatches so the user can see what each mode looks like before selecting,
317 // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
318 // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
319 val swatches: List<Color> = when (mode) {
320 ThemeMode.SYSTEM -> {
321 val dark = isSystemInDarkThemeStatic()
322 if (dark) DarkSwatches else LightSwatches
323 }
324 ThemeMode.LIGHT -> LightSwatches
325 ThemeMode.DARK -> DarkSwatches
326 }
327 Row(
328 modifier = Modifier
329 .fillMaxWidth()
330 .selectable(selected = selected, onClick = onSelect)
331 .padding(vertical = 8.dp),
332 horizontalArrangement = Arrangement.spacedBy(8.dp),
333 verticalAlignment = Alignment.CenterVertically,
334 ) {
335 RadioButton(selected = selected, onClick = null)
336 Text(
337 when (mode) {
338 ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
339 ThemeMode.LIGHT -> stringResource(R.string.theme_light)
340 ThemeMode.DARK -> stringResource(R.string.theme_dark)
341 },
342 style = MaterialTheme.typography.bodyLarge,
343 modifier = Modifier.weight(1f),
344 )
345 // Three small color dots previewing the palette for this mode.
346 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
347 swatches.forEach { color ->
348 Box(
349 modifier = Modifier
350 .size(14.dp)
351 .clip(CircleShape)
352 .background(color),
353 )
354 }
355 }
356 }
359// Palette preview dots for the theme rows. Primary/secondary/tertiary/background convey
360// the character of each scheme at a glance without rendering a full sample surface.
361private val DarkSwatches: List<Color> = DarkPaletteSwatches
362private val LightSwatches: List<Color> = LightPaletteSwatches
364// Composable-side read of the system dark setting for the "System" theme row preview.
365// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
366// helper; hoist it here so the ThemeRow stays a plain @Composable with no preview-time
367// recomposition surprises.
368@Composable
369private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
371/** Small wrapper so the three appearance settings load atomically and the UI can gate
372 * on a single null check instead of flashing hardcoded defaults. */
373private data class SettingsValues(
374 val themeMode: ThemeMode,
375 val dynamicColor: Boolean,
376 val sendOnEnter: Boolean,
379/** Small circular count badge used to indicate pending crash reports. */
380@Composable
381private fun Badge(count: Int) {
382 val overflow = stringResource(R.string.crash_count_overflow)
383 Box(
384 modifier = Modifier
385 .size(20.dp)
386 .clip(CircleShape)
387 .background(MaterialTheme.colorScheme.error),
388 contentAlignment = Alignment.Center,
389 ) {
390 Text(
391 if (count > 99) overflow else count.toString(),
392 style = MaterialTheme.typography.labelSmall,
393 color = MaterialTheme.colorScheme.onError,
394 textAlign = TextAlign.Center,
395 )
396 }