git.iko.soy/opencode-android
on master help sign in
UX/UI: FAB hides on keyboard, server URL hint, date separators in chat, hide FAB during search
7571397iko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+7−1
@@ -57,6 +57,9 @@ class OpencodeCore(
57 // Tracks the in-flight createSession coroutine so navigating away can cancel it
57 // Tracks the in-flight createSession coroutine so navigating away can cancel it
58 // before it dispatches SelectSession into a screen the user already left.
58 // before it dispatches SelectSession into a screen the user already left.
59 private var createSessionJob: Job? = null
59 private var createSessionJob: Job? = null
60+ // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61+ // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62+ private var loadMessagesJob: Job? = null
60 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
63 // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
61 private val removedMessageIds = mutableSetOf<String>()
64 private val removedMessageIds = mutableSetOf<String>()
62
65
@@ -104,7 +107,8 @@ class OpencodeCore(
104 generating = false
107 generating = false
105 error = null
108 error = null
106 emit()
109 emit()
110+ loadMessagesJob?.cancel()
107− scope.launch { loadMessages(event.id) }
111+ loadMessagesJob = scope.launch { loadMessages(event.id) }
108 }
112 }
109
113
110 Event.CreateSession -> {
114 Event.CreateSession -> {
@@ -164,6 +168,7 @@ class OpencodeCore(
164 loading = true
168 loading = true
165 emit()
169 emit()
166 createSessionJob?.cancel()
170 createSessionJob?.cancel()
171+ loadMessagesJob?.cancel()
167 loadSessionsJob?.cancel()
172 loadSessionsJob?.cancel()
168 loadSessionsJob = scope.launch { loadSessions() }
173 loadSessionsJob = scope.launch { loadSessions() }
169 }
174 }
@@ -182,6 +187,7 @@ class OpencodeCore(
182 // SSE connection can't flash the reconnecting banner on the next connect.
187 // SSE connection can't flash the reconnecting banner on the next connect.
183 sseConnected = true
188 sseConnected = true
184 createSessionJob?.cancel()
189 createSessionJob?.cancel()
190+ loadMessagesJob?.cancel()
185 closeSse()
191 closeSse()
186 emit()
192 emit()
187 }
193 }
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+58−3
@@ -21,11 +21,13 @@ import androidx.compose.foundation.layout.Spacer
21import androidx.compose.foundation.layout.fillMaxSize
21import androidx.compose.foundation.layout.fillMaxSize
22import androidx.compose.foundation.layout.fillMaxWidth
22import androidx.compose.foundation.layout.fillMaxWidth
23import androidx.compose.foundation.layout.PaddingValues
23import androidx.compose.foundation.layout.PaddingValues
24+import androidx.compose.foundation.layout.WindowInsets
25+import androidx.compose.foundation.layout.ime
24import androidx.compose.foundation.layout.imePadding
26import androidx.compose.foundation.layout.imePadding
25import androidx.compose.foundation.layout.padding
27import androidx.compose.foundation.layout.padding
26import androidx.compose.foundation.layout.size
28import androidx.compose.foundation.layout.size
27import androidx.compose.foundation.lazy.LazyColumn
29import androidx.compose.foundation.lazy.LazyColumn
28−import androidx.compose.foundation.lazy.items
30+import androidx.compose.foundation.lazy.itemsIndexed
29import androidx.compose.foundation.lazy.rememberLazyListState
31import androidx.compose.foundation.lazy.rememberLazyListState
30import androidx.compose.foundation.shape.CircleShape
32import androidx.compose.foundation.shape.CircleShape
31import androidx.compose.foundation.shape.RoundedCornerShape
33import androidx.compose.foundation.shape.RoundedCornerShape
@@ -63,9 +65,11 @@ import androidx.compose.ui.Alignment
63import androidx.compose.ui.Modifier
65import androidx.compose.ui.Modifier
64import androidx.compose.ui.draw.clip
66import androidx.compose.ui.draw.clip
65import androidx.compose.ui.hapticfeedback.HapticFeedbackType
67import androidx.compose.ui.hapticfeedback.HapticFeedbackType
68+import androidx.compose.ui.platform.LocalDensity
66import androidx.compose.ui.platform.LocalHapticFeedback
69import androidx.compose.ui.platform.LocalHapticFeedback
67import androidx.compose.ui.text.font.FontStyle
70import androidx.compose.ui.text.font.FontStyle
68import androidx.compose.ui.text.input.ImeAction
71import androidx.compose.ui.text.input.ImeAction
72+import androidx.compose.ui.text.style.TextAlign
69import androidx.compose.ui.text.style.TextOverflow
73import androidx.compose.ui.text.style.TextOverflow
70import androidx.compose.ui.unit.dp
74import androidx.compose.ui.unit.dp
71import kotlinx.coroutines.launch
75import kotlinx.coroutines.launch
@@ -79,6 +83,11 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
79 val listState = rememberLazyListState()
83 val listState = rememberLazyListState()
80 val coroutineScope = rememberCoroutineScope()
84 val coroutineScope = rememberCoroutineScope()
81
85
86+ // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
87+ // sit over the top of the IME. Reading the ime inset bottom during composition
88+ // observes snapshot state, so this flips as the keyboard opens/closes.
89+ val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
90+
82 // Show a jump-to-bottom button once the user scrolls up away from the latest
91 // Show a jump-to-bottom button once the user scrolls up away from the latest
83 // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
92 // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
84 // it only flips when the near-bottom threshold is actually crossed.
93 // it only flips when the near-bottom threshold is actually crossed.
@@ -180,7 +189,13 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
180 modifier = Modifier.fillMaxSize(),
189 modifier = Modifier.fillMaxSize(),
181 contentPadding = PaddingValues(vertical = 8.dp),
190 contentPadding = PaddingValues(vertical = 8.dp),
182 ) {
191 ) {
183− items(state.messages, key = { it.id }) { message ->
192+ itemsIndexed(state.messages, key = { _, message -> message.id }) { index, message ->
193+ // Insert a date separator whenever a message falls on a different
194+ // calendar day than the one before it (and above the first message).
195+ val previous = state.messages.getOrNull(index - 1)
196+ if (shouldShowDateSeparator(previous, message)) {
197+ DateSeparator(message.time)
198+ }
184 MessageBubble(
199 MessageBubble(
185 message,
200 message,
186 onRetry = { dispatch(Event.SendMessage(message.text)) },
201 onRetry = { dispatch(Event.SendMessage(message.text)) },
@@ -196,7 +211,7 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
196 }
211 }
197 }
212 }
198 ScrollToBottomButton(
213 ScrollToBottomButton(
199− visible = showScrollButton,
214+ visible = showScrollButton && !isImeVisible,
200 showBadge = hasUnseenMessages,
215 showBadge = hasUnseenMessages,
201 onClick = {
216 onClick = {
202 coroutineScope.launch {
217 coroutineScope.launch {
@@ -245,6 +260,46 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
245 }
260 }
246}
261}
247
262
263+private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
264+
265+/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
266+private fun localDayOf(epochMillis: Long): java.time.LocalDate =
267+ java.time.Instant.ofEpochMilli(epochMillis)
268+ .atZone(java.time.ZoneId.systemDefault())
269+ .toLocalDate()
270+
271+/**
272+ * A separator is shown above a message when it starts a new calendar day — i.e. the
273+ * first timestamped message, or one whose day differs from the previous message's.
274+ * Messages without a valid time (time <= 0) never get one.
275+ */
276+private fun shouldShowDateSeparator(previous: soy.iko.opencode.core.MessageView?, current: soy.iko.opencode.core.MessageView): Boolean {
277+ if (current.time <= 0) return false
278+ if (previous == null || previous.time <= 0) return true
279+ return localDayOf(previous.time) != localDayOf(current.time)
280+}
281+
282+/** Human-friendly day label: "Today", "Yesterday", or "MMM d" (e.g. "Jul 8"). */
283+private fun dateSeparatorLabel(day: java.time.LocalDate): String {
284+ val today = java.time.LocalDate.now()
285+ return when (day) {
286+ today -> "Today"
287+ today.minusDays(1) -> "Yesterday"
288+ else -> day.format(dateSeparatorFormat)
289+ }
290+}
291+
292+@Composable
293+private fun DateSeparator(epochMillis: Long) {
294+ Text(
295+ text = dateSeparatorLabel(localDayOf(epochMillis)),
296+ style = MaterialTheme.typography.labelSmall,
297+ color = MaterialTheme.colorScheme.onSurfaceVariant,
298+ textAlign = TextAlign.Center,
299+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
300+ )
301+}
302+
248// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
303// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
249// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
304// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
250// surfaceVariant look so it reads as an incoming message.
305// surfaceVariant look so it reads as an incoming message.
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+12
@@ -101,6 +101,18 @@ fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
101 enabled = !state.loading,
101 enabled = !state.loading,
102 )
102 )
103
103
104+ // Point first-time users at the expected address format. Hidden once they
105+ // start typing so it doesn't compete with the field's own contents.
106+ if (state.serverUrl.isEmpty()) {
107+ Spacer(Modifier.height(6.dp))
108+ Text(
109+ text = "Enter the URL of your opencode server (e.g. http://192.168.1.10:4096)",
110+ style = MaterialTheme.typography.bodySmall,
111+ color = MaterialTheme.colorScheme.onSurfaceVariant,
112+ textAlign = TextAlign.Center,
113+ )
114+ }
115+
104 if (state.authRequired) {
116 if (state.authRequired) {
105 Spacer(Modifier.height(12.dp))
117 Spacer(Modifier.height(12.dp))
106 OutlinedTextField(
118 OutlinedTextField(
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+9−5
@@ -112,11 +112,15 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
112 )
112 )
113 },
113 },
114 floatingActionButton = {
114 floatingActionButton = {
115+ // Give the search results the full width while searching — the FAB would
116+ // otherwise cover the bottom rows and isn't relevant mid-search anyway.
117+ if (searchQuery.isEmpty()) {
115− ExtendedFloatingActionButton(
118+ ExtendedFloatingActionButton(
116− onClick = { dispatch(Event.CreateSession) },
119+ onClick = { dispatch(Event.CreateSession) },
117− icon = { Icon(Icons.Filled.Add, contentDescription = null) },
120+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
118− text = { Text("New session") },
121+ text = { Text("New session") },
119− )
122+ )
123+ }
120 },
124 },
121 ) { padding ->
125 ) { padding ->
122 Column(Modifier.fillMaxSize().padding(padding)) {
126 Column(Modifier.fillMaxSize().padding(padding)) {
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+12
@@ -14,7 +14,13 @@ import androidx.compose.ui.platform.LocalContext
14private val DarkColors = darkColorScheme(
14private val DarkColors = darkColorScheme(
15 primary = Emerald,
15 primary = Emerald,
16 onPrimary = Color(0xFF0F1115),
16 onPrimary = Color(0xFF0F1115),
17+ primaryContainer = Color(0xFF1A3D2A),
18+ onPrimaryContainer = Color(0xFFA8E6C1),
17 secondary = EmeraldDark,
19 secondary = EmeraldDark,
20+ tertiaryContainer = Color(0xFF2A2A3D),
21+ onTertiaryContainer = Color(0xFFC5C5E6),
22+ errorContainer = Color(0xFF4D2626),
23+ onErrorContainer = Color(0xFFFFB4B4),
18 background = DarkBackground,
24 background = DarkBackground,
19 surface = DarkSurface,
25 surface = DarkSurface,
20 surfaceVariant = DarkSurfaceVariant,
26 surfaceVariant = DarkSurfaceVariant,
@@ -24,7 +30,13 @@ private val DarkColors = darkColorScheme(
24
30
25private val LightColors = lightColorScheme(
31private val LightColors = lightColorScheme(
26 primary = EmeraldDark,
32 primary = EmeraldDark,
33+ primaryContainer = Color(0xFFD7F5E3),
34+ onPrimaryContainer = Color(0xFF0A3D22),
27 secondary = Emerald,
35 secondary = Emerald,
36+ tertiaryContainer = Color(0xFFE8E8F5),
37+ onTertiaryContainer = Color(0xFF2A2A4D),
38+ errorContainer = Color(0xFFFFE0E0),
39+ onErrorContainer = Color(0xFF660000),
28 background = LightBackground,
40 background = LightBackground,
29 surface = LightSurface,
41 surface = LightSurface,
30 surfaceVariant = LightSurfaceVariant,
42 surfaceVariant = LightSurfaceVariant,