git.iko.soy/opencode-android
on master help sign in
UX/UI: animated scroll FAB, keyboard send action, empty state icon, chat loading indicator
ce9b0b6iko
app/src/main/java/soy/iko/opencode/CoreViewModel.kt+4
@@ -24,6 +24,10 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) {
24 prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
24 prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
25 prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
25 prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
26
26
27+ // On a fresh process after a configuration change, resume the SSE stream if
28+ // the core is already connected (no-op otherwise — it guards on `connected`).
29+ core.resumeStreamingIfConnected()
30+
27 // Persist connection details once a connection succeeds.
31 // Persist connection details once a connection succeeds.
28 viewModelScope.launch {
32 viewModelScope.launch {
29 core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
33 core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+6−1
@@ -134,6 +134,7 @@ class OpencodeCore(
134 screen = Screen.Connect
134 screen = Screen.Connect
135 connected = false
135 connected = false
136 loading = false
136 loading = false
137+ generating = false
137 sessions.clear()
138 sessions.clear()
138 messages.clear()
139 messages.clear()
139 currentSessionId = null
140 currentSessionId = null
@@ -198,7 +199,10 @@ class OpencodeCore(
198 when {
199 when {
199 resp.code == 401 -> {
200 resp.code == 401 -> {
200 loading = false
201 loading = false
202+ // Always reveal the auth fields so the user can see/edit
203+ // their credentials — even when the ones supplied were wrong.
204+ authRequired = true
201− if (hasCreds) error = "Invalid credentials" else authRequired = true
205+ if (hasCreds) error = "Invalid credentials"
202 emit()
206 emit()
203 }
207 }
204 resp.code in 200..299 -> {
208 resp.code in 200..299 -> {
@@ -374,6 +378,7 @@ class OpencodeCore(
374 if (scoped) {
378 if (scoped) {
375 event.error?.let { error = it.message() }
379 event.error?.let { error = it.message() }
376 generating = false
380 generating = false
381+ messages.forEach { it.streaming = false }
377 }
382 }
378 }
383 }
379
384
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+51−19
@@ -1,7 +1,13 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.fadeIn
5+import androidx.compose.animation.fadeOut
6+import androidx.compose.animation.scaleIn
7+import androidx.compose.animation.scaleOut
3import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Box
10+import androidx.compose.foundation.layout.BoxScope
5import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxSize
@@ -13,6 +19,8 @@ import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
19import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
20import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.lazy.rememberLazyListState
21import androidx.compose.foundation.lazy.rememberLazyListState
22+import androidx.compose.foundation.text.KeyboardActions
23+import androidx.compose.foundation.text.KeyboardOptions
16import androidx.compose.material.icons.Icons
24import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
25import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.automirrored.filled.Send
26import androidx.compose.material.icons.automirrored.filled.Send
@@ -24,6 +32,7 @@ import androidx.compose.material3.FilledIconButton
24import androidx.compose.material3.FloatingActionButton
32import androidx.compose.material3.FloatingActionButton
25import androidx.compose.material3.Icon
33import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
34import androidx.compose.material3.IconButton
35+import androidx.compose.material3.LinearProgressIndicator
27import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.OutlinedTextField
37import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.Scaffold
38import androidx.compose.material3.Scaffold
@@ -38,6 +47,7 @@ import androidx.compose.runtime.remember
38import androidx.compose.runtime.rememberCoroutineScope
47import androidx.compose.runtime.rememberCoroutineScope
39import androidx.compose.ui.Alignment
48import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
49import androidx.compose.ui.Modifier
50+import androidx.compose.ui.text.input.ImeAction
41import androidx.compose.ui.text.style.TextOverflow
51import androidx.compose.ui.text.style.TextOverflow
42import androidx.compose.ui.unit.dp
52import androidx.compose.ui.unit.dp
43import kotlinx.coroutines.launch
53import kotlinx.coroutines.launch
@@ -117,6 +127,9 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
117 if (!state.sseConnected && !state.generating) {
127 if (!state.sseConnected && !state.generating) {
118 ReconnectingBanner()
128 ReconnectingBanner()
119 }
129 }
130+ if (state.loading && state.messages.isEmpty()) {
131+ LinearProgressIndicator(Modifier.fillMaxWidth())
132+ }
120 Box(Modifier.fillMaxWidth().weight(1f)) {
133 Box(Modifier.fillMaxWidth().weight(1f)) {
121 if (state.messages.isEmpty() && !state.loading) {
134 if (state.messages.isEmpty() && !state.loading) {
122 Text(
135 Text(
@@ -135,31 +148,46 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
135 MessageBubble(message)
148 MessageBubble(message)
136 }
149 }
137 }
150 }
151+ ScrollToBottomButton(
138− if (showScrollButton) {
152+ visible = showScrollButton,
139− FloatingActionButton(
140− onClick = {
153+ onClick = {
141− coroutineScope.launch {
154+ coroutineScope.launch {
142− if (state.messages.isNotEmpty()) {
155+ if (state.messages.isNotEmpty()) {
143− listState.animateScrollToItem(state.messages.size - 1)
156+ listState.animateScrollToItem(state.messages.size - 1)
144− }
145 }
157 }
146− },
158+ }
147− modifier = Modifier
159+ },
148− .align(Alignment.BottomEnd)
160+ )
149− .padding(16.dp)
150− .size(44.dp),
151− ) {
152− Icon(
153− Icons.Filled.KeyboardArrowDown,
154− contentDescription = "Scroll to latest message",
155− )
156− }
157− }
158 }
161 }
159 }
162 }
160 }
163 }
161}
164}
162
165
166+// Extracted into a BoxScope extension so the top-level AnimatedVisibility overload
167+// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
168+// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
169+@Composable
170+private fun BoxScope.ScrollToBottomButton(visible: Boolean, onClick: () -> Unit) {
171+ AnimatedVisibility(
172+ visible = visible,
173+ modifier = Modifier.align(Alignment.BottomEnd),
174+ enter = scaleIn() + fadeIn(),
175+ exit = scaleOut() + fadeOut(),
176+ ) {
177+ FloatingActionButton(
178+ onClick = onClick,
179+ modifier = Modifier
180+ .padding(16.dp)
181+ .size(44.dp),
182+ ) {
183+ Icon(
184+ Icons.Filled.KeyboardArrowDown,
185+ contentDescription = "Scroll to latest message",
186+ )
187+ }
188+ }
189+}
190+
163@Composable
191@Composable
164private fun ReconnectingBanner() {
192private fun ReconnectingBanner() {
165 Surface(
193 Surface(
@@ -205,6 +233,10 @@ private fun ChatInputBar(
205 modifier = Modifier.weight(1f),
233 modifier = Modifier.weight(1f),
206 placeholder = { Text("Message opencode…") },
234 placeholder = { Text("Message opencode…") },
207 maxLines = 5,
235 maxLines = 5,
236+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
237+ keyboardActions = KeyboardActions(onSend = {
238+ if (!generating && draft.isNotBlank()) onSend()
239+ }),
208 )
240 )
209 if (generating) {
241 if (generating) {
210 FilledIconButton(onClick = onStop) {
242 FilledIconButton(onClick = onStop) {
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+10
@@ -5,9 +5,12 @@ import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.PaddingValues
6import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.height
10import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.lazy.items
13import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
@@ -167,6 +170,13 @@ private fun EmptySessions() {
167 verticalArrangement = Arrangement.Center,
170 verticalArrangement = Arrangement.Center,
168 horizontalAlignment = Alignment.CenterHorizontally,
171 horizontalAlignment = Alignment.CenterHorizontally,
169 ) {
172 ) {
173+ Icon(
174+ Icons.Filled.ChatBubbleOutline,
175+ contentDescription = null,
176+ modifier = Modifier.size(64.dp),
177+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
178+ )
179+ Spacer(Modifier.height(16.dp))
170 Text(
180 Text(
171 text = "No sessions yet",
181 text = "No sessions yet",
172 style = MaterialTheme.typography.titleMedium,
182 style = MaterialTheme.typography.titleMedium,