git.iko.soy/opencode-android
on master help sign in
UX/UI: edge-to-edge display, adaptive bubble width, pull-to-refresh on sessions, haptic feedback on send/delete
aa7f54ciko
app/src/main/java/soy/iko/opencode/MainActivity.kt+2
@@ -3,6 +3,7 @@ package soy.iko.opencode
3import android.os.Bundle
3import android.os.Bundle
4import androidx.activity.ComponentActivity
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
5import androidx.activity.compose.setContent
6+import androidx.activity.enableEdgeToEdge
6import androidx.compose.runtime.getValue
7import androidx.compose.runtime.getValue
7import androidx.lifecycle.compose.collectAsStateWithLifecycle
8import androidx.lifecycle.compose.collectAsStateWithLifecycle
8import androidx.lifecycle.viewmodel.compose.viewModel
9import androidx.lifecycle.viewmodel.compose.viewModel
@@ -11,6 +12,7 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
11
12
12class MainActivity : ComponentActivity() {
13class MainActivity : ComponentActivity() {
13 override fun onCreate(savedInstanceState: Bundle?) {
14 override fun onCreate(savedInstanceState: Bundle?) {
15+ enableEdgeToEdge()
14 super.onCreate(savedInstanceState)
16 super.onCreate(savedInstanceState)
15 setContent {
17 setContent {
16 OpencodeTheme {
18 OpencodeTheme {
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5−3
@@ -115,7 +115,9 @@ class OpencodeCore(
115 is Event.SendMessage -> sendMessage(event.text)
115 is Event.SendMessage -> sendMessage(event.text)
116
116
117 Event.CancelGeneration -> {
117 Event.CancelGeneration -> {
118− generating = false; emit()
118+ generating = false
119+ messages.forEach { it.streaming = false }
120+ emit()
119 val id = currentSessionId ?: return
121 val id = currentSessionId ?: return
120 scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
122 scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
121 }
123 }
@@ -270,7 +272,7 @@ class OpencodeCore(
270 private suspend fun loadMessages(sessionId: String) {
272 private suspend fun loadMessages(sessionId: String) {
271 http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
273 http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
272 onSuccess = { resp ->
274 onSuccess = { resp ->
273− if (!isCurrent(sessionId)) return // user navigated away while loading
275+ if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
274 loading = false
276 loading = false
275 if (resp.code in 200..299) {
277 if (resp.code in 200..299) {
276 messages.clear()
278 messages.clear()
@@ -282,7 +284,7 @@ class OpencodeCore(
282 emit()
284 emit()
283 },
285 },
284 onFailure = { e ->
286 onFailure = { e ->
285− if (!isCurrent(sessionId)) return
287+ if (!isCurrent(sessionId)) { loading = false; return }
286 loading = false
288 loading = false
287 error = "Request failed: ${e.message}"
289 error = "Request failed: ${e.message}"
288 emit()
290 emit()
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+1
@@ -69,6 +69,7 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
69 ) {
69 ) {
70 Column(
70 Column(
71 modifier = Modifier
71 modifier = Modifier
72+ .fillMaxWidth(0.85f)
72 .widthIn(max = 320.dp)
73 .widthIn(max = 320.dp)
73 .clip(shape)
74 .clip(shape)
74 .background(bubbleColor)
75 .background(bubbleColor)
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+7−1
@@ -47,6 +47,8 @@ import androidx.compose.runtime.remember
47import androidx.compose.runtime.rememberCoroutineScope
47import androidx.compose.runtime.rememberCoroutineScope
48import androidx.compose.ui.Alignment
48import androidx.compose.ui.Alignment
49import androidx.compose.ui.Modifier
49import androidx.compose.ui.Modifier
50+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
51+import androidx.compose.ui.platform.LocalHapticFeedback
50import androidx.compose.ui.text.input.ImeAction
52import androidx.compose.ui.text.input.ImeAction
51import androidx.compose.ui.text.style.TextOverflow
53import androidx.compose.ui.text.style.TextOverflow
52import androidx.compose.ui.unit.dp
54import androidx.compose.ui.unit.dp
@@ -221,6 +223,7 @@ private fun ChatInputBar(
221 onSend: () -> Unit,
223 onSend: () -> Unit,
222 onStop: () -> Unit,
224 onStop: () -> Unit,
223) {
225) {
226+ val haptic = LocalHapticFeedback.current
224 Surface(tonalElevation = 3.dp) {
227 Surface(tonalElevation = 3.dp) {
225 Row(
228 Row(
226 modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
229 modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
@@ -244,7 +247,10 @@ private fun ChatInputBar(
244 }
247 }
245 } else {
248 } else {
246 FilledIconButton(
249 FilledIconButton(
247− onClick = onSend,
250+ onClick = {
251+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
252+ onSend()
253+ },
248 enabled = draft.isNotBlank(),
254 enabled = draft.isNotBlank(),
249 ) {
255 ) {
250 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
256 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+22−9
@@ -31,6 +31,8 @@ import androidx.compose.material3.Scaffold
31import androidx.compose.material3.Text
31import androidx.compose.material3.Text
32import androidx.compose.material3.TextButton
32import androidx.compose.material3.TextButton
33import androidx.compose.material3.TopAppBar
33import androidx.compose.material3.TopAppBar
34+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
35+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
34import androidx.compose.runtime.Composable
36import androidx.compose.runtime.Composable
35import androidx.compose.runtime.getValue
37import androidx.compose.runtime.getValue
36import androidx.compose.runtime.mutableStateOf
38import androidx.compose.runtime.mutableStateOf
@@ -38,6 +40,8 @@ import androidx.compose.runtime.remember
38import androidx.compose.runtime.setValue
40import androidx.compose.runtime.setValue
39import androidx.compose.ui.Alignment
41import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
42import androidx.compose.ui.Modifier
43+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
44+import androidx.compose.ui.platform.LocalHapticFeedback
41import androidx.compose.ui.text.style.TextOverflow
45import androidx.compose.ui.text.style.TextOverflow
42import androidx.compose.ui.unit.dp
46import androidx.compose.ui.unit.dp
43import soy.iko.opencode.core.Event
47import soy.iko.opencode.core.Event
@@ -47,6 +51,7 @@ import soy.iko.opencode.core.UiState
47@OptIn(ExperimentalMaterial3Api::class)
51@OptIn(ExperimentalMaterial3Api::class)
48@Composable
52@Composable
49fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
53fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
54+ val haptic = LocalHapticFeedback.current
50 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
55 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
51
56
52 pendingDeleteId?.let { deleteId ->
57 pendingDeleteId?.let { deleteId ->
@@ -57,6 +62,7 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
57 text = { Text(pendingSession?.title ?: deleteId) },
62 text = { Text(pendingSession?.title ?: deleteId) },
58 confirmButton = {
63 confirmButton = {
59 TextButton(onClick = {
64 TextButton(onClick = {
65+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
60 dispatch(Event.DeleteSession(deleteId))
66 dispatch(Event.DeleteSession(deleteId))
61 pendingDeleteId = null
67 pendingDeleteId = null
62 }) {
68 }) {
@@ -101,17 +107,24 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
101 }
107 }
102 when {
108 when {
103 state.sessions.isEmpty() && !state.loading -> EmptySessions()
109 state.sessions.isEmpty() && !state.loading -> EmptySessions()
104− else -> LazyColumn(
110+ else -> PullToRefreshBox(
111+ isRefreshing = state.loading,
112+ onRefresh = { dispatch(Event.LoadSessions) },
105 modifier = Modifier.fillMaxSize(),
113 modifier = Modifier.fillMaxSize(),
106− contentPadding = PaddingValues(bottom = 88.dp),
114+ state = rememberPullToRefreshState(),
107 ) {
115 ) {
116+ LazyColumn(
117+ modifier = Modifier.fillMaxSize(),
118+ contentPadding = PaddingValues(bottom = 88.dp),
119+ ) {
108− items(state.sessions, key = { it.id }) { session ->
120+ items(state.sessions, key = { it.id }) { session ->
109− SessionRow(
121+ SessionRow(
110− session = session,
122+ session = session,
111− onOpen = { dispatch(Event.SelectSession(session.id)) },
123+ onOpen = { dispatch(Event.SelectSession(session.id)) },
112− onDelete = { pendingDeleteId = session.id },
124+ onDelete = { pendingDeleteId = session.id },
113− )
125+ )
114− HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
126+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
127+ }
115 }
128 }
116 }
129 }
117 }
130 }
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt−1
@@ -1,6 +1,5 @@
1package soy.iko.opencode.ui.theme
1package soy.iko.opencode.ui.theme
2
2
3−import android.app.Activity
4import android.os.Build
3import android.os.Build
5import androidx.compose.foundation.isSystemInDarkTheme
4import androidx.compose.foundation.isSystemInDarkTheme
6import androidx.compose.material3.MaterialTheme
5import androidx.compose.material3.MaterialTheme