git.iko.soy/opencode-android
on master help sign in
Fix UI/UX bugs and add polish: separate error/info snackbar hosts, remove redundant sessions logout button, null-safe chat session id, lift crash-log disk reads out of composition, announce message body to TalkBack, add scroll-to-bottom FAB, disable send while generating, friendlier session ids, confirm-before-clear crash reports, conditional title ellipsis, user-bubble timestamps, URL validation, design-token gap, Android 12+ splash screen
commitf34974c270f7e06e69fcf4f746f9b47827df137f authoriko <mail@iko.soy> dateWed 8 Jul 2026, 10:52 (3 months ago) parent4c143bb
11 files changed+331−70
app/src/main/java/soy/iko/opencode/CrashLogger.kt+14 app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt+87−22 app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt+36−2 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+33−2 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+80−15 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+33−7 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+16−21 app/src/main/java/soy/iko/opencode/ui/theme/Dimens.kt+8 app/src/main/res/values-night-v31/themes.xml+7 app/src/main/res/values-v31/themes.xml+11 app/src/main/res/values/strings.xml+6−1
app/src/main/java/soy/iko/opencode/CrashLogger.kt+14
@@ -2,6 +2,9 @@ package soy.iko.opencode
22
33import android.content.Context
44import android.util.Log
5+import kotlinx.coroutines.flow.MutableStateFlow
6+import kotlinx.coroutines.flow.StateFlow
7+import kotlinx.coroutines.flow.asStateFlow
58import java.io.File
69import java.io.PrintWriter
710import java.io.StringWriter
@@ -27,8 +30,13 @@ object CrashLogger {
2730 private lateinit var appContext: Context
2831 private var previousHandler: Thread.UncaughtExceptionHandler? = null
2932
33+ private val _reportCount = MutableStateFlow(0)
34+ /** Live count of persisted crash reports, safe to observe in composition. */
35+ val reportCount: StateFlow<Int> = _reportCount.asStateFlow()
36+
3037 fun init(context: Context) {
3138 appContext = context.applicationContext
39+ refreshReportCount()
3240 previousHandler = Thread.getDefaultUncaughtExceptionHandler()
3341 Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
3442 report(throwable, thread.name)
@@ -36,6 +44,10 @@ object CrashLogger {
3644 }
3745 }
3846
47+ private fun refreshReportCount() {
48+ _reportCount.value = getReports().size
49+ }
50+
3951 /**
4052 * Persist a crash report and log it. Safe to call from any thread,
4153 * including the crashing thread.
@@ -57,6 +69,7 @@ object CrashLogger {
5769 */
5870 fun clearReports() {
5971 crashDir().listFiles()?.forEach { it.delete() }
72+ refreshReportCount()
6073 }
6174
6275 private fun persistReport(source: String, stackTrace: String) {
@@ -65,6 +78,7 @@ object CrashLogger {
6578 val file = File(crashDir(), "crash_${timestamp}.txt")
6679 file.writeText("Time: $timestamp\nThread: $source\n\n$stackTrace")
6780 pruneOldReports()
81+ refreshReportCount()
6882 } catch (e: Exception) {
6983 Log.e(TAG, "Failed to persist crash report", e)
7084 }
app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt+87−22
@@ -13,6 +13,10 @@ import androidx.compose.material3.MaterialTheme
1313import androidx.compose.material3.Text
1414import androidx.compose.material3.TextButton
1515import androidx.compose.runtime.Composable
16+import androidx.compose.runtime.getValue
17+import androidx.compose.runtime.mutableStateOf
18+import androidx.compose.runtime.remember
19+import androidx.compose.runtime.setValue
1620import androidx.compose.ui.Modifier
1721import androidx.compose.ui.tooling.preview.Preview
1822import androidx.compose.ui.unit.dp
@@ -25,6 +29,14 @@ import java.io.File
2529 * (header) and the full stack trace (body). Height wraps content but is
2630 * capped at 60% of the viewport via [Modifier.heightIn] so very long traces
2731 * scroll internally without consuming the whole screen.
32+ *
33+ * Crash report file contents are read once via [remember] (off the first
34+ * recomposition) rather than on every recomposition — [File.readText] is disk
35+ * I/O and shouldn't run in composition repeatedly.
36+ *
37+ * "Clear all" is a destructive action: it's placed in the dismiss slot (left,
38+ * neutral) and tinted with the error color, and a confirmation step guards
39+ * against accidental taps. "Close" is the affirmative (right) action.
2840 */
2941@Composable
3042fun CrashLogDialog(
@@ -35,42 +47,95 @@ fun CrashLogDialog(
3547 title: String = "Crash Reports",
3648 clearLabel: String = "Clear all",
3749 closeLabel: String = "Close",
50+ clearConfirmLabel: String = "Clear all crash reports?",
51+ clearConfirmYesLabel: String = "Clear",
3852) {
53+ // Read each report's text once, not on every recomposition.
54+ val contents = remember(reports) {
55+ reports.map { it.name to it.readText() }
56+ }
57+ var confirmingClear by remember { mutableStateOf(false) }
58+
3959 AlertDialog(
40− onDismissRequest = onDismiss,
60+ onDismissRequest = {
61+ if (confirmingClear) confirmingClear = false else onDismiss()
62+ },
4163 modifier = modifier,
4264 title = { Text(title) },
4365 text = {
44− Column(
45− modifier = Modifier
46− .fillMaxWidth()
47− .heightIn(max = 360.dp)
48− .verticalScroll(rememberScrollState()),
49− ) {
50− reports.forEachIndexed { index, file ->
51− Text(
52− text = file.name,
53− style = MaterialTheme.typography.labelMedium,
54− color = MaterialTheme.colorScheme.primary,
55− )
56− Spacer(Modifier.height(Dimens.spaceTiny))
57− Text(
58− text = file.readText(),
59− style = MaterialTheme.typography.bodySmall,
60− )
61− if (index < reports.lastIndex) {
62− Spacer(Modifier.height(Dimens.spaceMedium))
66+ if (confirmingClear) {
67+ Text(
68+ text = clearConfirmLabel,
69+ style = MaterialTheme.typography.bodyMedium,
70+ )
71+ } else {
72+ Column(
73+ modifier = Modifier
74+ .fillMaxWidth()
75+ .heightIn(max = 360.dp)
76+ .verticalScroll(rememberScrollState()),
77+ ) {
78+ contents.forEachIndexed { index, (name, text) ->
79+ Text(
80+ text = name,
81+ style = MaterialTheme.typography.labelMedium,
82+ color = MaterialTheme.colorScheme.primary,
83+ )
84+ Spacer(Modifier.height(Dimens.spaceTiny))
85+ Text(
86+ text = text,
87+ style = MaterialTheme.typography.bodySmall,
88+ )
89+ if (index < contents.lastIndex) {
90+ Spacer(Modifier.height(Dimens.spaceMedium))
91+ }
6392 }
6493 }
6594 }
6695 },
6796 confirmButton = {
68− TextButton(onClick = onClear) { Text(clearLabel) }
97+ // Primary (right) button: "Close" is the non-destructive action.
98+ TextButton(onClick = onDismiss) { Text(closeLabel) }
6999 },
70100 dismissButton = {
71− TextButton(onClick = onDismiss) { Text(closeLabel) }
101+ if (confirmingClear) {
102+ // Confirm step: the affirmative "Clear" sits on the right now,
103+ // so expose a "Cancel" on the left to abort.
104+ TextButton(onClick = { confirmingClear = false }) { Text(closeLabel) }
105+ } else {
106+ // Destructive action in the dismiss slot (left), tinted error.
107+ TextButton(onClick = { confirmingClear = true }) {
108+ Text(
109+ text = clearLabel,
110+ color = MaterialTheme.colorScheme.error,
111+ )
112+ }
113+ }
72114 },
73115 )
116+
117+ // Second-stage confirmation dialog: actually clear when the user confirms.
118+ if (confirmingClear) {
119+ AlertDialog(
120+ onDismissRequest = { confirmingClear = false },
121+ title = { Text(title) },
122+ text = { Text(clearConfirmLabel) },
123+ confirmButton = {
124+ TextButton(onClick = {
125+ onClear()
126+ confirmingClear = false
127+ }) {
128+ Text(
129+ text = clearConfirmYesLabel,
130+ color = MaterialTheme.colorScheme.error,
131+ )
132+ }
133+ },
134+ dismissButton = {
135+ TextButton(onClick = { confirmingClear = false }) { Text(closeLabel) }
136+ },
137+ )
138+ }
74139}
75140
76141@Preview(showBackground = true)
app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt+36−2
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.components
22
3+import androidx.compose.foundation.layout.Column
34import androidx.compose.material3.MaterialTheme
45import androidx.compose.material3.Snackbar
56import androidx.compose.material3.SnackbarDuration
@@ -60,14 +61,19 @@ fun ErrorHost(
6061 * Collects transient success/info messages from [Core.info] and surfaces them
6162 * as a short snackbar. This gives the user positive feedback (e.g. "Session
6263 * created") instead of only ever showing errors.
64+ *
65+ * Uses a dedicated [SnackbarHostState] (separate from the error host) so the
66+ * info snackbar can be rendered with default colors rather than the error
67+ * palette. The screens render both hosts in the Scaffold's `snackbarHost`
68+ * slot; typically only one fires at a time.
6369 */
6470@Composable
6571fun InfoHost(
6672 core: Core,
6773 successConnectedLabel: String,
6874 successSessionCreatedLabel: String,
69− snackbarHostState: SnackbarHostState,
70−) {
75+): SnackbarHostState {
76+ val snackbarHostState = remember { SnackbarHostState() }
7177 LaunchedEffect(snackbarHostState) {
7278 core.info.collect { key ->
7379 val message = when (key) {
@@ -81,6 +87,7 @@ fun InfoHost(
8187 )
8288 }
8389 }
90+ return snackbarHostState
8491}
8592
8693@Composable
@@ -94,3 +101,30 @@ fun ErrorSnackbarHost(snackbarHostState: SnackbarHostState) {
94101 )
95102 }
96103}
104+
105+/**
106+ * Snackbar host for success/info messages. Uses the default (surface-tonal)
107+ * colors so positive feedback doesn't read as an error.
108+ */
109+@Composable
110+fun InfoSnackbarHost(snackbarHostState: SnackbarHostState) {
111+ SnackbarHost(snackbarHostState) { data ->
112+ Snackbar(snackbarData = data)
113+ }
114+}
115+
116+/**
117+ * Renders both the error and info snackbar hosts stacked vertically. The
118+ * Material 3 [Scaffold] only accepts a single `snackbarHost` slot, so screens
119+ * pass this helper to show both flavors without one overwriting the other.
120+ */
121+@Composable
122+fun DualSnackbarHost(
123+ errorHostState: SnackbarHostState,
124+ infoHostState: SnackbarHostState,
125+) {
126+ Column {
127+ ErrorSnackbarHost(errorHostState)
128+ InfoSnackbarHost(infoHostState)
129+ }
130+}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+33−2
@@ -14,6 +14,7 @@ import androidx.compose.material3.MaterialTheme
1414import androidx.compose.material3.Surface
1515import androidx.compose.material3.Text
1616import androidx.compose.runtime.Composable
17+import androidx.compose.runtime.remember
1718import androidx.compose.ui.Modifier
1819import androidx.compose.ui.platform.LocalDensity
1920import androidx.compose.ui.platform.LocalWindowInfo
@@ -59,11 +60,25 @@ fun MessageBubble(
5960 } else {
6061 stringResource(R.string.chat_role_assistant)
6162 }
63+ val timeLabel = remember(message.time) { formatTimestamp(message.time) }
64+ // Build a single TalkBack utterance that includes the role and the
65+ // message body. Setting an explicit contentDescription on a merge node
66+ // replaces descendant text, so we must include the body ourselves rather
67+ // than letting it merge — otherwise TalkBack reads only "User".
68+ val announcement = buildString {
69+ append(roleLabel)
70+ if (message.time > 0uL) {
71+ append(", ")
72+ append(timeLabel)
73+ }
74+ append(". ")
75+ append(if (message.text.isBlank()) stringResource(R.string.chat_no_content) else message.text)
76+ }
6277 Row(
6378 modifier = modifier
6479 .fillMaxWidth()
6580 .semantics(mergeDescendants = true) {
66− contentDescription = roleLabel
81+ contentDescription = announcement
6782 },
6883 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
6984 ) {
@@ -100,13 +115,29 @@ fun MessageBubble(
100115 )
101116 if (message.time > 0uL) {
102117 Text(
103− text = formatTimestamp(message.time),
118+ text = timeLabel,
104119 style = MaterialTheme.typography.labelSmall,
105120 color = MaterialTheme.colorScheme.onSurfaceVariant,
106121 )
107122 }
108123 }
109124 Spacer(Modifier.height(Dimens.spaceTiny))
125+ } else {
126+ // Show a timestamp on user bubbles too, aligned to the
127+ // end so it reads as the message's send time.
128+ if (message.time > 0uL) {
129+ Row(
130+ modifier = Modifier.fillMaxWidth(),
131+ horizontalArrangement = Arrangement.End,
132+ ) {
133+ Text(
134+ text = timeLabel,
135+ style = MaterialTheme.typography.labelSmall,
136+ color = MaterialTheme.colorScheme.onPrimary,
137+ )
138+ }
139+ Spacer(Modifier.height(Dimens.spaceTiny))
140+ }
110141 }
111142 if (message.text.isBlank()) {
112143 Text(
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+80−15
@@ -7,12 +7,15 @@ import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.PaddingValues
88import androidx.compose.foundation.layout.fillMaxSize
99import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
1011import androidx.compose.foundation.lazy.LazyColumn
1112import androidx.compose.foundation.lazy.items
1213import androidx.compose.foundation.lazy.rememberLazyListState
1314import androidx.compose.material.icons.Icons
1415import androidx.compose.material.icons.automirrored.filled.ArrowBack
16+import androidx.compose.material.icons.filled.KeyboardArrowDown
1517import androidx.compose.material3.ExperimentalMaterial3Api
18+import androidx.compose.material3.FloatingActionButton
1619import androidx.compose.material3.Icon
1720import androidx.compose.material3.IconButton
1821import androidx.compose.material3.MaterialTheme
@@ -24,9 +27,11 @@ import androidx.compose.runtime.LaunchedEffect
2427import androidx.compose.runtime.collectAsState
2528import androidx.compose.runtime.derivedStateOf
2629import androidx.compose.runtime.getValue
30+import androidx.compose.runtime.mutableIntStateOf
2731import androidx.compose.runtime.mutableStateOf
2832import androidx.compose.runtime.remember
2933import androidx.compose.runtime.setValue
34+import androidx.compose.ui.Alignment
3035import androidx.compose.ui.Modifier
3136import androidx.compose.ui.res.stringResource
3237import androidx.compose.ui.text.style.TextOverflow
@@ -38,9 +43,9 @@ import soy.iko.opencode.core.Event
3843import soy.iko.opencode.core.MessageView
3944import soy.iko.opencode.core.SseState
4045import soy.iko.opencode.ui.components.ChatInputBar
46+import soy.iko.opencode.ui.components.DualSnackbarHost
4147import soy.iko.opencode.ui.components.EmptyState
4248import soy.iko.opencode.ui.components.ErrorHost
43−import soy.iko.opencode.ui.components.ErrorSnackbarHost
4449import soy.iko.opencode.ui.components.GeneratingIndicator
4550import soy.iko.opencode.ui.components.InfoHost
4651import soy.iko.opencode.ui.components.LoadingPlaceholder
@@ -56,6 +61,7 @@ fun ChatScreen(core: Core) {
5661 val sseState by core.sseState.collectAsState()
5762 var inputText by remember { mutableStateOf("") }
5863 val listState = rememberLazyListState()
64+ var scrollRequest by remember { mutableIntStateOf(0) }
5965
6066 // Track whether the user is parked at the bottom of the list. We only
6167 // auto-scroll when they are, so reading history isn't yanked away as
@@ -73,37 +79,66 @@ fun ChatScreen(core: Core) {
7379 }
7480 }
7581
76− val snackbarHostState = ErrorHost(
82+ // Scroll-to-bottom when the user taps the FAB. Keyed on a counter so
83+ // repeated taps keep firing.
84+ LaunchedEffect(scrollRequest) {
85+ if (scrollRequest > 0 && view.messages.isNotEmpty()) {
86+ // Index of the trailing __generating__ item (it's last in the
87+ // list, at position == messages.size). Falling back to the last
88+ // message is safe even if the generating item is invisible.
89+ listState.animateScrollToItem(view.messages.size)
90+ }
91+ }
92+
93+ val errorHostState = ErrorHost(
7794 core = core,
7895 dismissLabel = stringResource(R.string.action_dismiss),
7996 retryLabel = stringResource(R.string.action_retry),
8097 )
81− InfoHost(
98+ val infoHostState = InfoHost(
8299 core = core,
83100 successConnectedLabel = stringResource(R.string.connect_success),
84101 successSessionCreatedLabel = stringResource(R.string.session_created),
85− snackbarHostState = snackbarHostState,
86102 )
87103
88104 val showSseBanner = sseState is SseState.Error ||
89105 (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
90106
107+ // Resolve nullable session fields into locals once per recomposition so we
108+ // never smart-cast a delegate-backed collected property across calls.
109+ val sessionId = view.currentSessionId
110+ val sessionTitle = view.currentSessionTitle
111+ val chatFallback = stringResource(R.string.top_bar_chat_fallback)
112+ val idShortTemplate = stringResource(R.string.chat_session_id_short)
113+ val title = remember(sessionId, sessionTitle) {
114+ when {
115+ sessionTitle.isNotEmpty() -> sessionTitle
116+ sessionId != null -> {
117+ val prefix = sessionId.take(8)
118+ if (sessionId.length > prefix.length) "$prefix…" else prefix
119+ }
120+ else -> chatFallback
121+ }
122+ }
123+ val subtitle = remember(sessionId, sessionTitle) {
124+ if (sessionTitle.isNotEmpty() && sessionId != null) {
125+ idShortTemplate.format(sessionId.take(8))
126+ } else null
127+ }
128+
91129 Scaffold(
92130 topBar = {
93131 TopAppBar(
94132 title = {
95133 Column {
96134 Text(
97− text = view.currentSessionTitle.ifEmpty {
98− view.currentSessionId?.take(8)?.plus("…")
99− ?: stringResource(R.string.top_bar_chat_fallback)
100− },
135+ text = title,
101136 maxLines = 1,
102137 overflow = TextOverflow.Ellipsis,
103138 )
104− if (view.currentSessionTitle.isNotEmpty() && view.currentSessionId != null) {
139+ if (subtitle != null) {
105140 Text(
106− text = view.currentSessionId!!.take(8),
141+ text = subtitle,
107142 style = MaterialTheme.typography.labelSmall,
108143 color = MaterialTheme.colorScheme.onSurfaceVariant,
109144 maxLines = 1,
@@ -131,12 +166,14 @@ fun ChatScreen(core: Core) {
131166 inputText = ""
132167 }
133168 },
134− enabled = !view.loading,
169+ // Disable while loading OR while the assistant is generating,
170+ // so users can't fire a second message mid-reply.
171+ enabled = !view.loading && !view.generating,
135172 placeholder = stringResource(R.string.chat_input_placeholder),
136173 sendContentDescription = stringResource(R.string.chat_cd_send),
137174 )
138175 },
139− snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
176+ snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
140177 ) { padding ->
141178 Box(
142179 modifier = Modifier
@@ -184,6 +221,28 @@ fun ChatScreen(core: Core) {
184221 }
185222 }
186223 }
224+
225+ // Scroll-to-bottom FAB: only when the user has scrolled up away
226+ // from the latest message and there's content to scroll to.
227+ if (view.messages.isNotEmpty() && !isAtBottom) {
228+ FloatingActionButton(
229+ onClick = { scrollRequest++ },
230+ modifier = Modifier
231+ .align(Alignment.BottomEnd)
232+ .padding(
233+ end = Dimens.fabEndMargin,
234+ bottom = Dimens.fabBottomMargin,
235+ )
236+ .size(Dimens.fabSize),
237+ containerColor = MaterialTheme.colorScheme.primaryContainer,
238+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
239+ ) {
240+ Icon(
241+ Icons.Default.KeyboardArrowDown,
242+ contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
243+ )
244+ }
245+ }
187246 }
188247 }
189248}
@@ -242,14 +301,20 @@ private fun PreviewChatScreen(
242301 messages: List<MessageView>,
243302 generating: Boolean,
244303) {
304+ val resolvedTitle = when {
305+ title.isNotEmpty() -> title
306+ sessionId != null -> {
307+ val prefix = sessionId.take(8)
308+ if (sessionId.length > prefix.length) "$prefix…" else prefix
309+ }
310+ else -> "Chat"
311+ }
245312 Scaffold(
246313 topBar = {
247314 TopAppBar(
248315 title = {
249316 Text(
250− text = title.ifEmpty {
251− sessionId?.take(8)?.plus("…") ?: "Chat"
252− },
317+ text = resolvedTitle,
253318 maxLines = 1,
254319 overflow = TextOverflow.Ellipsis,
255320 )
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+33−7
@@ -28,6 +28,7 @@ import androidx.compose.material3.IconButton
2828import androidx.compose.material3.MaterialTheme
2929import androidx.compose.material3.OutlinedTextField
3030import androidx.compose.material3.Scaffold
31+import androidx.compose.material3.SnackbarHostState
3132import androidx.compose.material3.Text
3233import androidx.compose.material3.TextButton
3334import androidx.compose.material3.TopAppBar
@@ -55,30 +56,33 @@ import soy.iko.opencode.R
5556import soy.iko.opencode.core.Core
5657import soy.iko.opencode.core.Event
5758import soy.iko.opencode.ui.components.CrashLogDialog
59+import soy.iko.opencode.ui.components.DualSnackbarHost
5860import soy.iko.opencode.ui.components.ErrorHost
5961import soy.iko.opencode.ui.components.ErrorSnackbarHost
6062import soy.iko.opencode.ui.components.InfoHost
63+import soy.iko.opencode.ui.components.InfoSnackbarHost
6164import soy.iko.opencode.ui.theme.Dimens
6265
6366@OptIn(ExperimentalMaterial3Api::class)
6467@Composable
6568fun ConnectScreen(core: Core) {
6669 val view by core.view.collectAsState()
70+ val crashCount by CrashLogger.reportCount.collectAsState()
6771 var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
6872 var username by remember(view.username) { mutableStateOf(view.username) }
6973 var password by remember(view.password) { mutableStateOf(view.password) }
7074 var passwordVisible by remember { mutableStateOf(false) }
7175 var showCrashLogs by remember { mutableStateOf(false) }
72− val snackbarHostState = ErrorHost(
76+ var urlError by remember { mutableStateOf(false) }
77+ val errorHostState = ErrorHost(
7378 core = core,
7479 dismissLabel = stringResource(R.string.action_dismiss),
7580 retryLabel = stringResource(R.string.action_retry),
7681 )
77− InfoHost(
82+ val infoHostState = InfoHost(
7883 core = core,
7984 successConnectedLabel = stringResource(R.string.connect_success),
8085 successSessionCreatedLabel = stringResource(R.string.session_created),
81− snackbarHostState = snackbarHostState,
8286 )
8387
8488 // Auto-focus the username field when the server reveals it requires
@@ -90,7 +94,19 @@ fun ConnectScreen(core: Core) {
9094 }
9195 }
9296
97+ fun isValidUrl(value: String): Boolean {
98+ val trimmed = value.trim()
99+ if (trimmed.isEmpty()) return false
100+ return trimmed.startsWith("http://", ignoreCase = true) ||
101+ trimmed.startsWith("https://", ignoreCase = true)
102+ }
103+
93104 fun submit() {
105+ if (!isValidUrl(url)) {
106+ urlError = true
107+ return
108+ }
109+ urlError = false
94110 core.update(Event.ServerUrlChanged(url))
95111 if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
96112 core.update(Event.UsernameChanged(username))
@@ -103,7 +119,9 @@ fun ConnectScreen(core: Core) {
103119 topBar = {
104120 TopAppBar(title = { Text(stringResource(R.string.top_bar_connect)) })
105121 },
106− snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
122+ snackbarHost = {
123+ DualSnackbarHost(errorHostState, infoHostState)
124+ },
107125 ) { padding ->
108126 Column(
109127 horizontalAlignment = Alignment.CenterHorizontally,
@@ -128,9 +146,16 @@ fun ConnectScreen(core: Core) {
128146 Spacer(Modifier.height(Dimens.spaceXLarge))
129147 OutlinedTextField(
130148 value = url,
131− onValueChange = { url = it },
149+ onValueChange = {
150+ url = it
151+ if (urlError) urlError = false
152+ },
132153 label = { Text(stringResource(R.string.connect_field_server_url)) },
133154 singleLine = true,
155+ isError = urlError,
156+ supportingText = if (urlError) {
157+ { Text(stringResource(R.string.connect_error_invalid_url)) }
158+ } else null,
134159 keyboardOptions = KeyboardOptions(
135160 keyboardType = KeyboardType.Uri,
136161 imeAction = ImeAction.Go,
@@ -224,7 +249,6 @@ fun ConnectScreen(core: Core) {
224249 }
225250 }
226251
227− val crashCount = CrashLogger.getReports().size
228252 if (crashCount > 0) {
229253 Spacer(Modifier.height(Dimens.spaceLarge))
230254 Row(verticalAlignment = Alignment.CenterVertically) {
@@ -234,7 +258,7 @@ fun ConnectScreen(core: Core) {
234258 tint = MaterialTheme.colorScheme.error,
235259 modifier = Modifier.size(Dimens.iconInputLeading),
236260 )
237− Spacer(Modifier.size(6.dp))
261+ Spacer(Modifier.size(Dimens.gapTiny))
238262 TextButton(onClick = { showCrashLogs = true }) {
239263 Text(
240264 text = pluralStringResource(R.plurals.crash_reports_view, crashCount, crashCount),
@@ -257,6 +281,8 @@ fun ConnectScreen(core: Core) {
257281 title = stringResource(R.string.crash_reports_title),
258282 clearLabel = stringResource(R.string.crash_reports_clear),
259283 closeLabel = stringResource(R.string.crash_reports_close),
284+ clearConfirmLabel = stringResource(R.string.crash_reports_clear_confirm),
285+ clearConfirmYesLabel = stringResource(R.string.crash_reports_clear_confirm_yes),
260286 )
261287 }
262288}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+16−21
@@ -10,7 +10,6 @@ import androidx.compose.material.icons.Icons
1010import androidx.compose.material.icons.automirrored.filled.ArrowBack
1111import androidx.compose.material.icons.filled.Add
1212import androidx.compose.material.icons.filled.ChatBubble
13−import androidx.compose.material.icons.automirrored.filled.Logout
1413import androidx.compose.material3.ExperimentalMaterial3Api
1514import androidx.compose.material3.Icon
1615import androidx.compose.material3.IconButton
@@ -35,9 +34,9 @@ import soy.iko.opencode.R
3534import soy.iko.opencode.core.Core
3635import soy.iko.opencode.core.Event
3736import soy.iko.opencode.core.SessionView
37+import soy.iko.opencode.ui.components.DualSnackbarHost
3838import soy.iko.opencode.ui.components.EmptyState
3939import soy.iko.opencode.ui.components.ErrorHost
40−import soy.iko.opencode.ui.components.ErrorSnackbarHost
4140import soy.iko.opencode.ui.components.InfoHost
4241import soy.iko.opencode.ui.components.LoadingPlaceholder
4342import soy.iko.opencode.ui.theme.Dimens
@@ -47,17 +46,18 @@ import soy.iko.opencode.ui.theme.OpencodeTheme
4746@Composable
4847fun SessionsScreen(core: Core) {
4948 val view by core.view.collectAsState()
50− val snackbarHostState = ErrorHost(
49+ val errorHostState = ErrorHost(
5150 core = core,
5251 dismissLabel = stringResource(R.string.action_dismiss),
5352 retryLabel = stringResource(R.string.action_retry),
5453 )
55− InfoHost(
54+ val infoHostState = InfoHost(
5655 core = core,
5756 successConnectedLabel = stringResource(R.string.connect_success),
5857 successSessionCreatedLabel = stringResource(R.string.session_created),
59− snackbarHostState = snackbarHostState,
6058 )
59+ val untitledLabel = stringResource(R.string.sessions_untitled)
60+ val idShortTemplate = stringResource(R.string.sessions_id_short)
6161
6262 Scaffold(
6363 topBar = {
@@ -78,19 +78,10 @@ fun SessionsScreen(core: Core) {
7878 contentDescription = stringResource(R.string.sessions_cd_new),
7979 )
8080 }
81− // Disconnect: returns to the Connect screen to switch
82− // servers / credentials. Labeled and iconed as logout so
83− // users understand it ends the session, not "settings".
84− IconButton(onClick = { core.update(Event.NavigateToConnect) }) {
85− Icon(
86− Icons.AutoMirrored.Filled.Logout,
87− contentDescription = stringResource(R.string.sessions_cd_disconnect),
88− )
89− }
9081 },
9182 )
9283 },
93− snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
84+ snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
9485 ) { padding ->
9586 Box(
9687 modifier = Modifier
@@ -114,7 +105,8 @@ fun SessionsScreen(core: Core) {
114105 items(view.sessions, key = { it.id }) { session ->
115106 SessionRow(
116107 session = session,
117− untitledLabel = stringResource(R.string.sessions_untitled),
108+ untitledLabel = untitledLabel,
109+ idShortLabel = idShortTemplate.format(session.id.take(8)),
118110 onClick = { core.update(Event.SelectSession(session.id)) },
119111 )
120112 }
@@ -130,12 +122,13 @@ fun SessionsScreen(core: Core) {
130122private fun SessionRow(
131123 session: SessionView,
132124 untitledLabel: String,
125+ idShortLabel: String,
133126 onClick: () -> Unit,
134127) {
135128 val title = session.title.ifEmpty { untitledLabel }
136129 ListItem(
137130 headlineContent = { Text(title) },
138− supportingContent = { Text(session.id.take(8)) },
131+ supportingContent = { Text(idShortLabel) },
139132 leadingContent = {
140133 Icon(
141134 Icons.Default.ChatBubble,
@@ -172,9 +165,6 @@ private fun SessionsScreenPreview() {
172165 IconButton(onClick = {}) {
173166 Icon(Icons.Default.Add, contentDescription = "New session")
174167 }
175− IconButton(onClick = {}) {
176− Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect")
177− }
178168 },
179169 )
180170 }) { padding ->
@@ -187,7 +177,12 @@ private fun SessionsScreenPreview() {
187177 ),
188178 key = { it.id },
189179 ) { session ->
190− SessionRow(session, "Untitled", onClick = {})
180+ SessionRow(
181+ session,
182+ untitledLabel = "Untitled",
183+ idShortLabel = "Session ${session.id.take(8)}",
184+ onClick = {},
185+ )
191186 }
192187 }
193188 }
app/src/main/java/soy/iko/opencode/ui/theme/Dimens.kt+8
@@ -23,6 +23,9 @@ object Dimens {
2323 val spaceLarge = 16.dp
2424 val spaceXLarge = 24.dp
2525
26+ // Inline gaps (not large enough for a named "space" token but still shared)
27+ val gapTiny = 6.dp
28+
2629 // Bubble internals
2730 val bubbleHorizontalPadding = 14.dp
2831 val bubbleVerticalPadding = 10.dp
@@ -42,4 +45,9 @@ object Dimens {
4245
4346 // Strokes
4447 val strokeThin = 2.dp
48+
49+ // Floating action button
50+ val fabSize = 40.dp
51+ val fabEndMargin = 16.dp
52+ val fabBottomMargin = 12.dp
4553}
app/src/main/res/values-night-v31/themes.xmlnew file+7
@@ -0,0 +1,7 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.Opencode" parent="android:Theme.Material.NoActionBar">
4+ <item name="android:windowSplashScreenBackground">@android:color/black</item>
5+ <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
6+ </style>
7+</resources>
app/src/main/res/values-v31/themes.xmlnew file+11
@@ -0,0 +1,11 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.Opencode" parent="android:Theme.Material.Light.NoActionBar">
4+ <!-- Android 12+ system splash screen: use the window background color
5+ so the post-splash crossfade into the Compose UI is seamless. The
6+ Compose UI paints its own background, so we only set the poster
7+ color and a system icon here. -->
8+ <item name="android:windowSplashScreenBackground">@android:color/white</item>
9+ <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
10+ </style>
11+</resources>
app/src/main/res/values/strings.xml+6−1
@@ -20,6 +20,7 @@
2020 <string name="connect_cd_dns">Connection icon</string>
2121 <string name="connect_cd_lock">Credentials icon</string>
2222 <string name="connect_cd_warning">Warning icon</string>
23+ <string name="connect_error_invalid_url">Enter a valid URL (http:// or https://)</string>
2324
2425 <!-- Crash log -->
2526 <string name="crash_reports_title">Crash Reports</string>
@@ -29,13 +30,15 @@
2930 </plurals>
3031 <string name="crash_reports_clear">Clear all</string>
3132 <string name="crash_reports_close">Close</string>
33+ <string name="crash_reports_clear_confirm">Clear all crash reports?</string>
34+ <string name="crash_reports_clear_confirm_yes">Clear</string>
3235
3336 <!-- Sessions screen -->
3437 <string name="sessions_empty">No sessions yet</string>
3538 <string name="sessions_cd_back">Change server</string>
3639 <string name="sessions_cd_new">New session</string>
37− <string name="sessions_cd_disconnect">Disconnect</string>
3840 <string name="sessions_untitled">Untitled</string>
41+ <string name="sessions_id_short">Session %s</string>
3942 <string name="session_created">Session created</string>
4043
4144 <!-- Chat screen -->
@@ -48,6 +51,8 @@
4851 <string name="chat_role_user">User</string>
4952 <string name="chat_role_assistant">Assistant</string>
5053 <string name="chat_cd_generating">Generating reply</string>
54+ <string name="chat_cd_scroll_to_bottom">Scroll to latest message</string>
55+ <string name="chat_session_id_short">Session %s</string>
5156
5257 <!-- Error / snackbar -->
5358 <string name="action_dismiss">Dismiss</string>