git.iko.soy/opencode-android
on master help sign in
UX/UI: app icon on connect screen, dismiss action on error snackbar, new-messages badge on scroll FAB, swipe-to-delete on sessions
9ffe468iko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+3
@@ -62,6 +62,9 @@ class OpencodeCore(
62 Event.Connect -> {
62 Event.Connect -> {
63 serverUrl = normalizeUrl(serverUrl)
63 serverUrl = normalizeUrl(serverUrl)
64 if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
64 if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
65+ // The auth fields are showing but empty — sending the request now
66+ // would just 401 again in a loop. Prompt for credentials instead.
67+ if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
65 loading = true; error = null; authRequired = false; emit()
68 loading = true; error = null; authRequired = false; emit()
66 scope.launch { probeHealth() }
69 scope.launch { probeHealth() }
67 }
70 }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−1
@@ -9,8 +9,10 @@ import androidx.compose.animation.togetherWith
9import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
11import androidx.compose.material3.Scaffold
11import androidx.compose.material3.Scaffold
12+import androidx.compose.material3.SnackbarDuration
12import androidx.compose.material3.SnackbarHost
13import androidx.compose.material3.SnackbarHost
13import androidx.compose.material3.SnackbarHostState
14import androidx.compose.material3.SnackbarHostState
15+import androidx.compose.material3.SnackbarResult
14import androidx.compose.runtime.Composable
16import androidx.compose.runtime.Composable
15import androidx.compose.runtime.LaunchedEffect
17import androidx.compose.runtime.LaunchedEffect
16import androidx.compose.runtime.remember
18import androidx.compose.runtime.remember
@@ -28,7 +30,15 @@ fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
28
30
29 LaunchedEffect(state.error) {
31 LaunchedEffect(state.error) {
30 state.error?.let {
32 state.error?.let {
31− snackbar.showSnackbar(it)
33+ val result = snackbar.showSnackbar(
34+ message = it,
35+ actionLabel = "Dismiss",
36+ duration = SnackbarDuration.Short,
37+ )
38+ if (result == SnackbarResult.ActionPerformed) {
39+ dispatch(Event.DismissError)
40+ }
41+ // Always clear the error from state after showing.
32 dispatch(Event.DismissError)
42 dispatch(Event.DismissError)
33 }
43 }
34 }
44 }
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+2−5
@@ -37,15 +37,12 @@ import androidx.compose.ui.platform.LocalContext
37import androidx.compose.ui.text.font.FontStyle
37import androidx.compose.ui.text.font.FontStyle
38import androidx.compose.ui.text.style.TextOverflow
38import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.unit.dp
39import androidx.compose.ui.unit.dp
40−import java.text.SimpleDateFormat
41−import java.util.Date
42−import java.util.Locale
43import soy.iko.opencode.core.MessageStatus
40import soy.iko.opencode.core.MessageStatus
44import soy.iko.opencode.core.MessageView
41import soy.iko.opencode.core.MessageView
45import soy.iko.opencode.core.ToolView
42import soy.iko.opencode.core.ToolView
46import soy.iko.opencode.ui.theme.MonoStyle
43import soy.iko.opencode.ui.theme.MonoStyle
47
44
48−private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
45+private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
49
46
50@OptIn(ExperimentalFoundationApi::class)
47@OptIn(ExperimentalFoundationApi::class)
51@Composable
48@Composable
@@ -100,7 +97,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
100
97
101 if (message.time > 0) {
98 if (message.time > 0) {
102 Text(
99 Text(
103− text = timeFormat.format(Date(message.time)),
100+ text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
104 style = MaterialTheme.typography.labelSmall,
101 style = MaterialTheme.typography.labelSmall,
105 color = mutedColor,
102 color = mutedColor,
106 modifier = Modifier.padding(top = 4.dp),
103 modifier = Modifier.padding(top = 4.dp),
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+36−11
@@ -5,6 +5,7 @@ import androidx.compose.animation.fadeIn
5import androidx.compose.animation.fadeOut
5import androidx.compose.animation.fadeOut
6import androidx.compose.animation.scaleIn
6import androidx.compose.animation.scaleIn
7import androidx.compose.animation.scaleOut
7import androidx.compose.animation.scaleOut
8+import androidx.compose.foundation.background
8import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.BoxScope
11import androidx.compose.foundation.layout.BoxScope
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.size
19import androidx.compose.foundation.lazy.LazyColumn
20import androidx.compose.foundation.lazy.LazyColumn
20import androidx.compose.foundation.lazy.items
21import androidx.compose.foundation.lazy.items
21import androidx.compose.foundation.lazy.rememberLazyListState
22import androidx.compose.foundation.lazy.rememberLazyListState
23+import androidx.compose.foundation.shape.CircleShape
22import androidx.compose.foundation.text.KeyboardActions
24import androidx.compose.foundation.text.KeyboardActions
23import androidx.compose.foundation.text.KeyboardOptions
25import androidx.compose.foundation.text.KeyboardOptions
24import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.Icons
@@ -43,10 +45,13 @@ import androidx.compose.runtime.Composable
43import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.LaunchedEffect
44import androidx.compose.runtime.derivedStateOf
46import androidx.compose.runtime.derivedStateOf
45import androidx.compose.runtime.getValue
47import androidx.compose.runtime.getValue
48+import androidx.compose.runtime.mutableStateOf
46import androidx.compose.runtime.remember
49import androidx.compose.runtime.remember
47import androidx.compose.runtime.rememberCoroutineScope
50import androidx.compose.runtime.rememberCoroutineScope
51+import androidx.compose.runtime.setValue
48import androidx.compose.ui.Alignment
52import androidx.compose.ui.Alignment
49import androidx.compose.ui.Modifier
53import androidx.compose.ui.Modifier
54+import androidx.compose.ui.draw.clip
50import androidx.compose.ui.hapticfeedback.HapticFeedbackType
55import androidx.compose.ui.hapticfeedback.HapticFeedbackType
51import androidx.compose.ui.platform.LocalHapticFeedback
56import androidx.compose.ui.platform.LocalHapticFeedback
52import androidx.compose.ui.text.input.ImeAction
57import androidx.compose.ui.text.input.ImeAction
@@ -75,6 +80,16 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
75 }
80 }
76 }
81 }
77
82
83+ // Track how many messages the user has "seen" (i.e. was at the bottom for). While
84+ // they're scrolled up, any messages beyond that count are unseen — flag the FAB.
85+ var lastSeenCount by remember { mutableStateOf(0) }
86+ LaunchedEffect(showScrollButton, state.messages.size) {
87+ if (!showScrollButton) {
88+ lastSeenCount = state.messages.size
89+ }
90+ }
91+ val hasUnseenMessages = showScrollButton && state.messages.size > lastSeenCount
92+
78 // Keep the newest content in view as it streams in, but only when the user is
93 // Keep the newest content in view as it streams in, but only when the user is
79 // already near the bottom. If they've scrolled up to read history, leave it alone.
94 // already near the bottom. If they've scrolled up to read history, leave it alone.
80 val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
95 val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
@@ -152,6 +167,7 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
152 }
167 }
153 ScrollToBottomButton(
168 ScrollToBottomButton(
154 visible = showScrollButton,
169 visible = showScrollButton,
170+ showBadge = hasUnseenMessages,
155 onClick = {
171 onClick = {
156 coroutineScope.launch {
172 coroutineScope.launch {
157 if (state.messages.isNotEmpty()) {
173 if (state.messages.isNotEmpty()) {
@@ -169,23 +185,32 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
169// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
185// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
170// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
186// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
171@Composable
187@Composable
172−private fun BoxScope.ScrollToBottomButton(visible: Boolean, onClick: () -> Unit) {
188+private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean, onClick: () -> Unit) {
173 AnimatedVisibility(
189 AnimatedVisibility(
174 visible = visible,
190 visible = visible,
175 modifier = Modifier.align(Alignment.BottomEnd),
191 modifier = Modifier.align(Alignment.BottomEnd),
176 enter = scaleIn() + fadeIn(),
192 enter = scaleIn() + fadeIn(),
177 exit = scaleOut() + fadeOut(),
193 exit = scaleOut() + fadeOut(),
178 ) {
194 ) {
179− FloatingActionButton(
180− onClick = onClick,
181− modifier = Modifier
182− .padding(16.dp)
195+ Box(Modifier.padding(16.dp)) {
196+ FloatingActionButton(
197+ onClick = onClick,
183− .size(44.dp),
198+ modifier = Modifier.size(44.dp),
184− ) {
199+ ) {
185− Icon(
200+ Icon(
186− Icons.Filled.KeyboardArrowDown,
201+ Icons.Filled.KeyboardArrowDown,
187− contentDescription = "Scroll to latest message",
202+ contentDescription = "Scroll to latest message",
188− )
203+ )
204+ }
205+ if (showBadge) {
206+ Box(
207+ modifier = Modifier
208+ .align(Alignment.TopEnd)
209+ .size(8.dp)
210+ .clip(CircleShape)
211+ .background(MaterialTheme.colorScheme.error),
212+ )
213+ }
189 }
214 }
190 }
215 }
191}
216}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+9
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.height
8import androidx.compose.foundation.layout.height
9import androidx.compose.foundation.layout.imePadding
9import androidx.compose.foundation.layout.imePadding
10import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.rememberScrollState
12import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardActions
13import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.foundation.text.KeyboardOptions
@@ -15,6 +16,7 @@ import androidx.compose.foundation.verticalScroll
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.Lock
17import androidx.compose.material.icons.filled.Lock
17import androidx.compose.material.icons.filled.Person
18import androidx.compose.material.icons.filled.Person
19+import androidx.compose.material.icons.filled.Terminal
18import androidx.compose.material3.Button
20import androidx.compose.material3.Button
19import androidx.compose.material3.CircularProgressIndicator
21import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.Icon
22import androidx.compose.material3.Icon
@@ -43,6 +45,13 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
43 verticalArrangement = Arrangement.Center,
45 verticalArrangement = Arrangement.Center,
44 horizontalAlignment = Alignment.CenterHorizontally,
46 horizontalAlignment = Alignment.CenterHorizontally,
45 ) {
47 ) {
48+ Icon(
49+ Icons.Filled.Terminal,
50+ contentDescription = null,
51+ tint = MaterialTheme.colorScheme.primary,
52+ modifier = Modifier.size(72.dp),
53+ )
54+ Spacer(Modifier.height(16.dp))
46 Text(
55 Text(
47 text = "opencode",
56 text = "opencode",
48 style = MaterialTheme.typography.displaySmall,
57 style = MaterialTheme.typography.displaySmall,
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+58−7
@@ -1,7 +1,9 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
3+import androidx.compose.foundation.background
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Row
@@ -28,12 +30,16 @@ import androidx.compose.material3.IconButton
28import androidx.compose.material3.LinearProgressIndicator
30import androidx.compose.material3.LinearProgressIndicator
29import androidx.compose.material3.MaterialTheme
31import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.Scaffold
32import androidx.compose.material3.Scaffold
33+import androidx.compose.material3.SwipeToDismissBox
34+import androidx.compose.material3.SwipeToDismissBoxValue
31import androidx.compose.material3.Text
35import androidx.compose.material3.Text
32import androidx.compose.material3.TextButton
36import androidx.compose.material3.TextButton
33import androidx.compose.material3.TopAppBar
37import androidx.compose.material3.TopAppBar
38+import androidx.compose.material3.rememberSwipeToDismissBoxState
34import androidx.compose.material3.pulltorefresh.PullToRefreshBox
39import androidx.compose.material3.pulltorefresh.PullToRefreshBox
35import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
40import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
36import androidx.compose.runtime.Composable
41import androidx.compose.runtime.Composable
42+import androidx.compose.runtime.LaunchedEffect
37import androidx.compose.runtime.getValue
43import androidx.compose.runtime.getValue
38import androidx.compose.runtime.mutableStateOf
44import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.remember
45import androidx.compose.runtime.remember
@@ -53,6 +59,13 @@ import soy.iko.opencode.core.UiState
53fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
59fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
54 val haptic = LocalHapticFeedback.current
60 val haptic = LocalHapticFeedback.current
55 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
61 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
62+ var isPullRefreshing by remember { mutableStateOf(false) }
63+
64+ // The pull-to-refresh spinner should only reflect a user-initiated pull, not
65+ // every loading operation. Clear it once the underlying load completes.
66+ LaunchedEffect(state.loading) {
67+ if (!state.loading) isPullRefreshing = false
68+ }
56
69
57 pendingDeleteId?.let { deleteId ->
70 pendingDeleteId?.let { deleteId ->
58 val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
71 val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
@@ -102,14 +115,17 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
102 },
115 },
103 ) { padding ->
116 ) { padding ->
104 Column(Modifier.fillMaxSize().padding(padding)) {
117 Column(Modifier.fillMaxSize().padding(padding)) {
105− if (state.loading) {
118+ if (state.loading && !isPullRefreshing) {
106 LinearProgressIndicator(Modifier.fillMaxWidth())
119 LinearProgressIndicator(Modifier.fillMaxWidth())
107 }
120 }
108 when {
121 when {
109 state.sessions.isEmpty() && !state.loading -> EmptySessions()
122 state.sessions.isEmpty() && !state.loading -> EmptySessions()
110 else -> PullToRefreshBox(
123 else -> PullToRefreshBox(
111− isRefreshing = state.loading,
124+ isRefreshing = isPullRefreshing,
125+ onRefresh = {
126+ isPullRefreshing = true
112− onRefresh = { dispatch(Event.LoadSessions) },
127+ dispatch(Event.LoadSessions)
128+ },
113 modifier = Modifier.fillMaxSize(),
129 modifier = Modifier.fillMaxSize(),
114 state = rememberPullToRefreshState(),
130 state = rememberPullToRefreshState(),
115 ) {
131 ) {
@@ -118,11 +134,27 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
118 contentPadding = PaddingValues(bottom = 88.dp),
134 contentPadding = PaddingValues(bottom = 88.dp),
119 ) {
135 ) {
120 items(state.sessions, key = { it.id }) { session ->
136 items(state.sessions, key = { it.id }) { session ->
121− SessionRow(
137+ val dismissState = rememberSwipeToDismissBoxState(
122− session = session,
138+ // Only a swipe from the end (right-to-left) triggers deletion;
123− onOpen = { dispatch(Event.SelectSession(session.id)) },
139+ // return false so the row snaps back and the confirm dialog decides.
140+ confirmValueChange = { value ->
141+ if (value == SwipeToDismissBoxValue.EndToStart) {
124− onDelete = { pendingDeleteId = session.id },
142+ pendingDeleteId = session.id
143+ }
144+ false
145+ },
125 )
146 )
147+ SwipeToDismissBox(
148+ state = dismissState,
149+ enableDismissFromStartToEnd = false,
150+ backgroundContent = { SwipeDeleteBackground() },
151+ ) {
152+ SessionRow(
153+ session = session,
154+ onOpen = { dispatch(Event.SelectSession(session.id)) },
155+ onDelete = { pendingDeleteId = session.id },
156+ )
157+ }
126 HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
158 HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
127 }
159 }
128 }
160 }
@@ -137,6 +169,7 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
137 Row(
169 Row(
138 modifier = Modifier
170 modifier = Modifier
139 .fillMaxWidth()
171 .fillMaxWidth()
172+ .background(MaterialTheme.colorScheme.surface)
140 .clickable(onClick = onOpen)
173 .clickable(onClick = onOpen)
141 .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
174 .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
142 verticalAlignment = Alignment.CenterVertically,
175 verticalAlignment = Alignment.CenterVertically,
@@ -172,6 +205,24 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
172 }
205 }
173}
206}
174
207
208+/** Red background with a trailing delete icon, revealed while swiping a session row away. */
209+@Composable
210+private fun SwipeDeleteBackground() {
211+ Box(
212+ modifier = Modifier
213+ .fillMaxSize()
214+ .background(MaterialTheme.colorScheme.error)
215+ .padding(horizontal = 24.dp),
216+ contentAlignment = Alignment.CenterEnd,
217+ ) {
218+ Icon(
219+ Icons.Filled.DeleteOutline,
220+ contentDescription = null,
221+ tint = MaterialTheme.colorScheme.onError,
222+ )
223+ }
224+}
225+
175/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
226/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
176private fun shortSessionId(id: String): String =
227private fun shortSessionId(id: String): String =
177 if (id.length > 12) id.take(8) + "…" else id
228 if (id.length > 12) id.take(8) + "…" else id