git.iko.soy/opencode-android
on master help sign in
Add 14 UX improvements: distinct find-prev/next a11y descriptions, SessionCard nested-button fix, FileRow combined git-status description, IME search-key actions in picker sheets + FindBar, removed double IME inset in chat, catalog-picker loading spinners, atomic settings load to fix default flash, session-creation FAB spinner, server-list pull-to-refresh, animateItem on session/server/diagnostics/file lists, styled no-sessions-match empty state, outlined Save button, queued-follow-up message preview
e1a6907iko
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+4
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.text.KeyboardActions
12import androidx.compose.foundation.text.KeyboardOptions
13import androidx.compose.foundation.text.KeyboardOptions
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.filled.Check
15import androidx.compose.material.icons.filled.Check
@@ -29,6 +30,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
29import androidx.compose.runtime.setValue
30import androidx.compose.runtime.setValue
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.Modifier
33+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
32import androidx.compose.ui.res.stringResource
34import androidx.compose.ui.res.stringResource
33import androidx.compose.ui.semantics.Role
35import androidx.compose.ui.semantics.Role
34import androidx.compose.ui.semantics.contentDescription
36import androidx.compose.ui.semantics.contentDescription
@@ -88,6 +90,7 @@ fun AgentPickerSheet(
88 )
90 )
89 } else {
91 } else {
90 var query by rememberSaveable { mutableStateOf("") }
92 var query by rememberSaveable { mutableStateOf("") }
93+ val keyboardController = LocalSoftwareKeyboardController.current
91 OutlinedTextField(
94 OutlinedTextField(
92 value = query,
95 value = query,
93 onValueChange = { query = it },
96 onValueChange = { query = it },
@@ -96,6 +99,7 @@ fun AgentPickerSheet(
96 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
99 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
97 singleLine = true,
100 singleLine = true,
98 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
101 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
102+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
99 )
103 )
100 val filtered = remember(agents, query) {
104 val filtered = remember(agents, query) {
101 val q = query.trim()
105 val q = query.trim()
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+54−15
@@ -83,6 +83,7 @@ import androidx.compose.ui.semantics.Role
83import androidx.compose.ui.semantics.contentDescription
83import androidx.compose.ui.semantics.contentDescription
84import androidx.compose.ui.semantics.semantics
84import androidx.compose.ui.semantics.semantics
85import androidx.compose.ui.text.input.ImeAction
85import androidx.compose.ui.text.input.ImeAction
86+import androidx.compose.ui.text.style.TextOverflow
86import androidx.compose.ui.unit.dp
87import androidx.compose.ui.unit.dp
87import androidx.lifecycle.compose.collectAsStateWithLifecycle
88import androidx.lifecycle.compose.collectAsStateWithLifecycle
88import androidx.lifecycle.viewmodel.compose.viewModel
89import androidx.lifecycle.viewmodel.compose.viewModel
@@ -304,14 +305,42 @@ fun ChatScreen(
304 ) {
305 ) {
305 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
306 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
306 }
307 }
308+ // Show a spinner while a catalog is initially loading (empty list +
309+ // loading flag) so the icon doesn't look permanently disabled. Once
310+ // loaded, the icon is always enabled — tapping a picker with no items
311+ // shows the "no models/agents/commands" or error-with-retry state.
312+ if (commandsLoading && commands.isEmpty()) {
313+ val loadingLabel = stringResource(R.string.loading)
314+ CircularProgressIndicator(
315+ Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
316+ strokeWidth = 2.dp,
317+ )
318+ } else {
307− IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
319+ IconButton(onClick = { showCommandPicker = true }) {
308− Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
320+ Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
321+ }
309 }
322 }
323+ if (agentsLoading && agents.isEmpty()) {
324+ val loadingLabel = stringResource(R.string.loading)
325+ CircularProgressIndicator(
326+ Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
327+ strokeWidth = 2.dp,
328+ )
329+ } else {
310− IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
330+ IconButton(onClick = { showAgentPicker = true }) {
311− Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
331+ Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
332+ }
312 }
333 }
334+ if (modelsLoading && models.isEmpty()) {
335+ val loadingLabel = stringResource(R.string.loading)
336+ CircularProgressIndicator(
337+ Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
338+ strokeWidth = 2.dp,
339+ )
340+ } else {
313− IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
341+ IconButton(onClick = { showModelPicker = true }) {
314− Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
342+ Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
343+ }
315 }
344 }
316 },
345 },
317 )
346 )
@@ -369,9 +398,11 @@ fun ChatScreen(
369 }
398 }
370
399
371 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
400 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
372− // imePadding so the jump-to-latest FAB (aligned BottomEnd) is pushed above the
401+ // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
373− // IME instead of being hidden behind it when the keyboard is open.
402+ // content padding accounts for the bottomBar's raised height, so the message
403+ // list is already above the keyboard. Adding imePadding here would double-apply
404+ // the IME inset and push messages too far up.
374− Box(modifier = Modifier.fillMaxSize().padding(padding).imePadding()) {
405+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
375 if (activeConnection == null) {
406 if (activeConnection == null) {
376 Column(
407 Column(
377 modifier = Modifier.align(Alignment.Center).padding(24.dp),
408 modifier = Modifier.align(Alignment.Center).padding(24.dp),
@@ -620,12 +651,20 @@ private fun ChatInputBar(
620 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
651 .padding(start = 12.dp, top = 6.dp, end = 4.dp),
621 verticalAlignment = Alignment.CenterVertically,
652 verticalAlignment = Alignment.CenterVertically,
622 ) {
653 ) {
623− Text(
654+ Column(modifier = Modifier.weight(1f)) {
655+ Text(
624− stringResource(R.string.queued),
656+ stringResource(R.string.queued),
625− style = MaterialTheme.typography.labelSmall,
657+ style = MaterialTheme.typography.labelSmall,
626− color = MaterialTheme.colorScheme.onSurfaceVariant,
658+ color = MaterialTheme.colorScheme.onSurfaceVariant,
627− modifier = Modifier.weight(1f),
659+ )
660+ Text(
661+ queuedFollowUp,
662+ style = MaterialTheme.typography.bodySmall,
663+ color = MaterialTheme.colorScheme.onSurfaceVariant,
664+ maxLines = 1,
665+ overflow = TextOverflow.Ellipsis,
628− )
666+ )
667+ }
629 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
668 TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
630 Text(stringResource(R.string.cancel))
669 Text(stringResource(R.string.cancel))
631 }
670 }
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+4
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.heightIn
7import androidx.compose.foundation.layout.padding
7import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.lazy.LazyColumn
8import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
9import androidx.compose.foundation.lazy.items
10+import androidx.compose.foundation.text.KeyboardActions
10import androidx.compose.foundation.text.KeyboardOptions
11import androidx.compose.foundation.text.KeyboardOptions
11import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
13import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
@@ -28,6 +29,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
28import androidx.compose.runtime.setValue
29import androidx.compose.runtime.setValue
29import androidx.compose.ui.Alignment
30import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
31import androidx.compose.ui.Modifier
32+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
31import androidx.compose.ui.res.stringResource
33import androidx.compose.ui.res.stringResource
32import androidx.compose.ui.semantics.Role
34import androidx.compose.ui.semantics.Role
33import androidx.compose.ui.semantics.contentDescription
35import androidx.compose.ui.semantics.contentDescription
@@ -86,6 +88,7 @@ fun CommandPickerSheet(
86 )
88 )
87 } else {
89 } else {
88 var query by rememberSaveable { mutableStateOf("") }
90 var query by rememberSaveable { mutableStateOf("") }
91+ val keyboardController = LocalSoftwareKeyboardController.current
89 OutlinedTextField(
92 OutlinedTextField(
90 value = query,
93 value = query,
91 onValueChange = { query = it },
94 onValueChange = { query = it },
@@ -94,6 +97,7 @@ fun CommandPickerSheet(
94 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
97 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
95 singleLine = true,
98 singleLine = true,
96 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
99 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
100+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
97 )
101 )
98 val filtered = remember(commands, query) {
102 val filtered = remember(commands, query) {
99 val q = query.trim()
103 val q = query.trim()
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+4
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
11import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.text.KeyboardActions
12import androidx.compose.foundation.text.KeyboardOptions
13import androidx.compose.foundation.text.KeyboardOptions
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.filled.Check
15import androidx.compose.material.icons.filled.Check
@@ -29,6 +30,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
29import androidx.compose.runtime.setValue
30import androidx.compose.runtime.setValue
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.Modifier
33+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
32import androidx.compose.ui.res.stringResource
34import androidx.compose.ui.res.stringResource
33import androidx.compose.ui.semantics.Role
35import androidx.compose.ui.semantics.Role
34import androidx.compose.ui.semantics.contentDescription
36import androidx.compose.ui.semantics.contentDescription
@@ -88,6 +90,7 @@ fun ModelPickerSheet(
88 )
90 )
89 } else {
91 } else {
90 var query by rememberSaveable { mutableStateOf("") }
92 var query by rememberSaveable { mutableStateOf("") }
93+ val keyboardController = LocalSoftwareKeyboardController.current
91 OutlinedTextField(
94 OutlinedTextField(
92 value = query,
95 value = query,
93 onValueChange = { query = it },
96 onValueChange = { query = it },
@@ -96,6 +99,7 @@ fun ModelPickerSheet(
96 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
99 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
97 singleLine = true,
100 singleLine = true,
98 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
101 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
102+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
99 )
103 )
100 val filtered = remember(options, query) {
104 val filtered = remember(options, query) {
101 val q = query.trim()
105 val q = query.trim()
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+23−11
@@ -206,6 +206,7 @@ private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
206 label = name,
206 label = name,
207 sublabel = dir.takeIf { it.isNotEmpty() },
207 sublabel = dir.takeIf { it.isNotEmpty() },
208 onClick = { onOpenFile(path) },
208 onClick = { onOpenFile(path) },
209+ modifier = Modifier.animateItem(),
209 )
210 )
210 HorizontalDivider()
211 HorizontalDivider()
211 }
212 }
@@ -240,6 +241,7 @@ private fun DirectoryListing(
240 label = node.name,
241 label = node.name,
241 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
242 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
242 status = state.statusMap[node.path],
243 status = state.statusMap[node.path],
244+ modifier = Modifier.animateItem(),
243 )
245 )
244 HorizontalDivider()
246 HorizontalDivider()
245 }
247 }
@@ -277,16 +279,29 @@ private fun FileRow(
277 onClick: () -> Unit,
279 onClick: () -> Unit,
278 status: FileStatusEntry? = null,
280 status: FileStatusEntry? = null,
279 sublabel: String? = null,
281 sublabel: String? = null,
282+ modifier: Modifier = Modifier,
280) {
283) {
281− val desc = if (label == "..") stringResource(R.string.parent_dir)
284+ val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
282 else if (icon) stringResource(R.string.folder, label)
285 else if (icon) stringResource(R.string.folder, label)
283 else stringResource(R.string.file_label, label)
286 else stringResource(R.string.file_label, label)
287+ // Build a combined description so TalkBack announces both the file name and its git
288+ // status. The parent sets an explicit contentDescription (mergeDescendants), which
289+ // would otherwise suppress the StatusBadge's own description.
290+ val statusDesc = status?.let {
291+ when (it.status) {
292+ "added" -> stringResource(R.string.git_added)
293+ "modified" -> stringResource(R.string.git_modified)
294+ "deleted" -> stringResource(R.string.git_deleted)
295+ else -> ""
296+ }
297+ }.orEmpty()
298+ val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
284 Row(
299 Row(
285− modifier = Modifier
300+ modifier = modifier
286 .fillMaxWidth()
301 .fillMaxWidth()
287 .defaultMinSize(minHeight = 48.dp)
302 .defaultMinSize(minHeight = 48.dp)
288 .clickable(role = Role.Button, onClick = onClick)
303 .clickable(role = Role.Button, onClick = onClick)
289− .semantics(mergeDescendants = true) { contentDescription = desc }
304+ .semantics(mergeDescendants = true) { contentDescription = fullDesc }
290 .padding(horizontal = 16.dp, vertical = 14.dp),
305 .padding(horizontal = 16.dp, vertical = 14.dp),
291 verticalAlignment = Alignment.CenterVertically,
306 verticalAlignment = Alignment.CenterVertically,
292 ) {
307 ) {
@@ -319,17 +334,14 @@ private fun FileRow(
319
334
320@Composable
335@Composable
321private fun StatusBadge(status: FileStatusEntry) {
336private fun StatusBadge(status: FileStatusEntry) {
322− val (letter, color, desc) = when (status.status) {
337+ val (letter, color) = when (status.status) {
323− "added" -> Triple("A", MaterialTheme.colorScheme.primary, stringResource(R.string.git_added))
338+ "added" -> "A" to MaterialTheme.colorScheme.primary
324− "modified" -> Triple("M", MaterialTheme.colorScheme.tertiary, stringResource(R.string.git_modified))
339+ "modified" -> "M" to MaterialTheme.colorScheme.tertiary
325− "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
340+ "deleted" -> "D" to MaterialTheme.colorScheme.error
326− else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
341+ else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
327 }
342 }
328 Row(
343 Row(
329 verticalAlignment = Alignment.CenterVertically,
344 verticalAlignment = Alignment.CenterVertically,
330− modifier = Modifier.semantics(mergeDescendants = true) {
331− if (desc.isNotEmpty()) contentDescription = desc
332− },
333 ) {
345 ) {
334 Text(
346 Text(
335 letter,
347 letter,
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+5−3
@@ -60,6 +60,7 @@ import androidx.compose.runtime.setValue
60import androidx.compose.ui.Alignment
60import androidx.compose.ui.Alignment
61import androidx.compose.ui.Modifier
61import androidx.compose.ui.Modifier
62import androidx.compose.ui.platform.LocalContext
62import androidx.compose.ui.platform.LocalContext
63+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
63import androidx.compose.ui.semantics.contentDescription
64import androidx.compose.ui.semantics.contentDescription
64import androidx.compose.ui.semantics.semantics
65import androidx.compose.ui.semantics.semantics
65import androidx.compose.ui.text.font.FontFamily
66import androidx.compose.ui.text.font.FontFamily
@@ -386,6 +387,7 @@ private fun FindBar(
386 onNext: () -> Unit,
387 onNext: () -> Unit,
387 onClose: () -> Unit,
388 onClose: () -> Unit,
388) {
389) {
390+ val keyboardController = LocalSoftwareKeyboardController.current
389 Row(
391 Row(
390 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
392 modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
391 verticalAlignment = Alignment.CenterVertically,
393 verticalAlignment = Alignment.CenterVertically,
@@ -397,7 +399,7 @@ private fun FindBar(
397 placeholder = { Text(stringResource(R.string.find_hint)) },
399 placeholder = { Text(stringResource(R.string.find_hint)) },
398 singleLine = true,
400 singleLine = true,
399 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
401 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
400− keyboardActions = KeyboardActions(onSearch = { onQueryChange(query) }),
402+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
401 trailingIcon = {
403 trailingIcon = {
402 val countText = if (query.isBlank()) ""
404 val countText = if (query.isBlank()) ""
403 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
405 else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
@@ -412,10 +414,10 @@ private fun FindBar(
412 },
414 },
413 )
415 )
414 IconButton(onClick = onPrev, enabled = matchCount > 0) {
416 IconButton(onClick = onPrev, enabled = matchCount > 0) {
415− Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_in_file))
417+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
416 }
418 }
417 IconButton(onClick = onNext, enabled = matchCount > 0) {
419 IconButton(onClick = onNext, enabled = matchCount > 0) {
418− Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_in_file))
420+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
419 }
421 }
420 IconButton(onClick = onClose) {
422 IconButton(onClick = onClose) {
421 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
423 Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+2−2
@@ -233,7 +233,7 @@ private fun ServerEditForm(
233 modifier = Modifier.fillMaxWidth(),
233 modifier = Modifier.fillMaxWidth(),
234 horizontalArrangement = Arrangement.spacedBy(12.dp),
234 horizontalArrangement = Arrangement.spacedBy(12.dp),
235 ) {
235 ) {
236− Button(
236+ OutlinedButton(
237 onClick = {
237 onClick = {
238 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
238 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
239 vm.save(onDone)
239 vm.save(onDone)
@@ -244,7 +244,7 @@ private fun ServerEditForm(
244 if (state.saving) {
244 if (state.saving) {
245 CircularProgressIndicator(
245 CircularProgressIndicator(
246 modifier = Modifier.size(20.dp),
246 modifier = Modifier.size(20.dp),
247− color = MaterialTheme.colorScheme.onPrimary,
247+ color = MaterialTheme.colorScheme.primary,
248 strokeWidth = 2.dp,
248 strokeWidth = 2.dp,
249 )
249 )
250 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
250 Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+31−22
@@ -39,6 +39,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue
39import androidx.compose.material3.Text
39import androidx.compose.material3.Text
40import androidx.compose.material3.TextButton
40import androidx.compose.material3.TextButton
41import androidx.compose.material3.TopAppBar
41import androidx.compose.material3.TopAppBar
42+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
42import androidx.compose.material3.rememberSwipeToDismissBoxState
43import androidx.compose.material3.rememberSwipeToDismissBoxState
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.LaunchedEffect
@@ -145,29 +146,35 @@ fun ServerListScreen(
145 modifier = Modifier.fillMaxSize().padding(padding),
146 modifier = Modifier.fillMaxSize().padding(padding),
146 )
147 )
147 } else {
148 } else {
148− LazyColumn(
149+ PullToRefreshBox(
150+ isRefreshing = connectingId != null,
151+ onRefresh = { vm.refresh(onConnected) },
149− modifier = Modifier.fillMaxSize().padding(padding),
152+ modifier = Modifier.fillMaxSize(),
150− contentPadding = androidx.compose.foundation.layout.PaddingValues(
151− start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
152− ),
153− verticalArrangement = Arrangement.spacedBy(12.dp),
154 ) {
153 ) {
154+ LazyColumn(
155+ modifier = Modifier.fillMaxSize().padding(padding),
156+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
157+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
158+ ),
159+ verticalArrangement = Arrangement.spacedBy(12.dp),
160+ ) {
155− items(profiles, key = { it.id }) { profile ->
161+ items(profiles, key = { it.id }) { profile ->
156− val isActive = profile.id == connectedId
162+ val isActive = profile.id == connectedId
157− if (isActive) {
163+ if (isActive) {
158− // The active server can't be swipe-deleted (deleting it also
164+ // The active server can't be swipe-deleted (deleting it also
159− // disconnects, which deserves an explicit tap, not an
165+ // disconnects, which deserves an explicit tap, not an
160− // accidental swipe). Render a plain card without the swipe
166+ // accidental swipe). Render a plain card without the swipe
161− // affordance for the active row.
167+ // affordance for the active row.
162− Card(
168+ Card(
163− modifier = Modifier
169+ modifier = Modifier
164− .fillMaxWidth()
170+ .fillMaxWidth()
171+ .animateItem()
165− .testTag("server_card")
172+ .testTag("server_card")
166− .clickable(enabled = connectingId == null, role = Role.Button) {
173+ .clickable(enabled = connectingId == null, role = Role.Button) {
167− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
174+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
168− vm.connect(profile, onConnected)
175+ vm.connect(profile, onConnected)
169− },
176+ },
170− ) {
177+ ) {
171 Row(
178 Row(
172 modifier = Modifier.fillMaxWidth().padding(16.dp),
179 modifier = Modifier.fillMaxWidth().padding(16.dp),
173 verticalAlignment = Alignment.CenterVertically,
180 verticalAlignment = Alignment.CenterVertically,
@@ -224,6 +231,7 @@ fun ServerListScreen(
224 SwipeToDismissBox(
231 SwipeToDismissBox(
225 state = swipeState,
232 state = swipeState,
226 enableDismissFromStartToEnd = false,
233 enableDismissFromStartToEnd = false,
234+ modifier = Modifier.animateItem(),
227 backgroundContent = {
235 backgroundContent = {
228 Box(
236 Box(
229 modifier = Modifier
237 modifier = Modifier
@@ -290,6 +298,7 @@ fun ServerListScreen(
290 }
298 }
291 }
299 }
292 }
300 }
301+ }
293 }
302 }
294 }
303 }
295 }
304 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+9
@@ -74,6 +74,15 @@ class ServerListViewModel(private val container: AppContainer) : ViewModel() {
74 }
74 }
75 }
75 }
76
76
77+ /** Retry connecting to the most recently used profile — the pull-to-refresh action on
78+ * the server list. Gives the user a familiar gesture to retry a failed auto-connect
79+ * instead of having to tap a server card. */
80+ fun refresh(onConnected: () -> Unit) {
81+ if (_connecting.value != null || container.reconnecting.value) return
82+ val target = profiles.value.firstOrNull() ?: return
83+ connect(target, onConnected)
84+ }
85+
77 /**
86 /**
78 * Mark [profile] for deferred deletion and emit an Undo event. The actual
87 * Mark [profile] for deferred deletion and emit an Undo event. The actual
79 * [ProfileStore.delete] is delayed by [NetworkConfig.undoServerDeleteDelayMs]; if
88 * [ProfileStore.delete] is delayed by [NetworkConfig.undoServerDeleteDelayMs]; if
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+36−8
@@ -210,8 +210,18 @@ fun SessionListScreen(
210 snackbarHost = { SnackbarHost(snackbar) },
210 snackbarHost = { SnackbarHost(snackbar) },
211 floatingActionButton = {
211 floatingActionButton = {
212 ExtendedFloatingActionButton(
212 ExtendedFloatingActionButton(
213− onClick = { vm.createSession(onCreated = onOpenSession) },
213+ onClick = { if (!creating) vm.createSession(onCreated = onOpenSession) },
214+ icon = {
215+ if (creating) {
216+ CircularProgressIndicator(
217+ Modifier.size(24.dp),
218+ strokeWidth = 2.dp,
219+ color = MaterialTheme.colorScheme.onPrimaryContainer,
220+ )
221+ } else {
214− icon = { Icon(Icons.Filled.Add, contentDescription = null) },
222+ Icon(Icons.Filled.Add, contentDescription = null)
223+ }
224+ },
215 text = { Text(stringResource(R.string.new_session)) },
225 text = { Text(stringResource(R.string.new_session)) },
216 // Disable while a creation is in flight so a double-tap can't spawn two
226 // Disable while a creation is in flight so a double-tap can't spawn two
217 // sessions. The container guard in createSession is the real protection;
227 // sessions. The container guard in createSession is the real protection;
@@ -355,11 +365,28 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
355 if (state.query.isBlank()) state.sessions else state.filtered
365 if (state.query.isBlank()) state.sessions else state.filtered
356 }
366 }
357 if (sessions.isEmpty()) {
367 if (sessions.isEmpty()) {
368+ Box(
369+ modifier = Modifier.fillMaxSize(),
370+ contentAlignment = Alignment.Center,
371+ ) {
372+ Column(
373+ horizontalAlignment = Alignment.CenterHorizontally,
374+ modifier = Modifier.padding(32.dp),
375+ ) {
376+ Icon(
377+ Icons.Filled.Search,
378+ contentDescription = null,
379+ modifier = Modifier.size(48.dp),
380+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
381+ )
382+ Spacer(Modifier.size(12.dp))
358− Text(
383+ Text(
359− stringResource(R.string.no_sessions_match, state.query),
384+ stringResource(R.string.no_sessions_match, state.query),
360− modifier = Modifier.padding(24.dp),
385+ style = MaterialTheme.typography.bodyMedium,
361− color = MaterialTheme.colorScheme.onSurfaceVariant,
386+ color = MaterialTheme.colorScheme.onSurfaceVariant,
362− )
387+ )
388+ }
389+ }
363 } else {
390 } else {
364 PullToRefreshBox(
391 PullToRefreshBox(
365 isRefreshing = refreshing,
392 isRefreshing = refreshing,
@@ -389,6 +416,7 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
389 SwipeToDismissBox(
416 SwipeToDismissBox(
390 state = swipeState,
417 state = swipeState,
391 enableDismissFromStartToEnd = false,
418 enableDismissFromStartToEnd = false,
419+ modifier = Modifier.animateItem(),
392 backgroundContent = {
420 backgroundContent = {
393 Box(
421 Box(
394 modifier = Modifier
422 modifier = Modifier
@@ -469,7 +497,7 @@ private fun SessionCard(
469 Card(
497 Card(
470 modifier = modifier
498 modifier = modifier
471 .fillMaxWidth()
499 .fillMaxWidth()
472− .clickable(role = Role.Button) { onClick() },
500+ .clickable { onClick() },
473 ) {
501 ) {
474 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
502 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
475 Row(
503 Row(
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+1
@@ -154,6 +154,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
154 SwipeToDismissBox(
154 SwipeToDismissBox(
155 state = swipeState,
155 state = swipeState,
156 enableDismissFromStartToEnd = false,
156 enableDismissFromStartToEnd = false,
157+ modifier = Modifier.animateItem(),
157 backgroundContent = {
158 backgroundContent = {
158 Box(
159 Box(
159 modifier = Modifier
160 modifier = Modifier
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+71−45
@@ -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:
104 },
112 },
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)) {
115+ val s = settings
107 Text(
116 Text(
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() },
112 )
121 )
122+ if (s != null) {
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) } } },
118− )
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+ }
119− }
159+ }
120
160
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) {
125 Row(
163 Row(
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,
132 )
170 )
133 .padding(vertical = 8.dp),
171 .padding(vertical = 8.dp),
134 verticalAlignment = Alignment.CenterVertically,
172 verticalAlignment = Alignment.CenterVertically,
135 ) {
173 ) {
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)
138 Text(
176 Text(
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,
142 )
180 )
143 }
181 }
144 Switch(
182 Switch(
145− checked = dynamicColor,
183+ checked = s.sendOnEnter,
146 onCheckedChange = null,
184 onCheckedChange = null,
147 )
185 )
148 }
186 }
149− }
187+ } else {
150−
188+ Box(
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),
189+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
162− verticalAlignment = Alignment.CenterVertically,
190+ contentAlignment = Alignment.Center,
163− ) {
191+ ) {
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)
166− Text(
167− stringResource(R.string.send_on_enter_desc),
168− style = MaterialTheme.typography.bodySmall,
169− color = MaterialTheme.colorScheme.onSurfaceVariant,
170− )
171 }
193 }
172− Switch(
173− checked = sendOnEnter,
174− onCheckedChange = null,
175− )
176 }
194 }
177
195
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()
313
331
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+
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) {
app/src/main/res/values/strings.xml+2
@@ -241,6 +241,8 @@
241 <string name="last_used">Last used %1$s</string>
241 <string name="last_used">Last used %1$s</string>
242 <string name="duplicate_server">Duplicate</string>
242 <string name="duplicate_server">Duplicate</string>
243 <string name="find_in_file">Find in file</string>
243 <string name="find_in_file">Find in file</string>
244+ <string name="find_previous">Find previous</string>
245+ <string name="find_next">Find next</string>
244 <string name="find_hint">Find…</string>
246 <string name="find_hint">Find…</string>
245 <string name="no_matches_in_file">No matches</string>
247 <string name="no_matches_in_file">No matches</string>
246 <string name="match_count">%1$d / %2$d</string>
248 <string name="match_count">%1$d / %2$d</string>