git.iko.soy/opencode-android
on 06fd41b help sign in
177 lines 9.1 kB history raw
1package soy.iko.opencode.ui.components
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.fadeIn
5import androidx.compose.animation.fadeOut
6import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.BoxScope
8import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.defaultMinSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.material3.CircularProgressIndicator
14import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.Surface
16import androidx.compose.material3.Text
17import androidx.compose.material3.TextButton
18import androidx.compose.runtime.Composable
19import androidx.compose.runtime.SideEffect
20import androidx.compose.runtime.getValue
21import androidx.compose.runtime.produceState
22import androidx.compose.runtime.remember
23import androidx.compose.runtime.mutableStateOf
24import androidx.compose.ui.Alignment
25import androidx.compose.ui.Modifier
26import androidx.compose.ui.res.stringResource
27import androidx.compose.ui.semantics.contentDescription
28import androidx.compose.ui.semantics.liveRegion
29import androidx.compose.ui.semantics.LiveRegionMode
30import androidx.compose.ui.semantics.semantics
31import androidx.compose.ui.unit.dp
32import androidx.lifecycle.compose.collectAsStateWithLifecycle
33import kotlinx.coroutines.ExperimentalCoroutinesApi
34import kotlinx.coroutines.flow.flatMapLatest
35import kotlinx.coroutines.flow.flowOf
36import soy.iko.opencode.R
37import soy.iko.opencode.data.network.EventStreamClient
38import soy.iko.opencode.di.AppContainer
40/**
41 * Banner shown when the SSE event stream is not connected. Shared by the chat and the
42 * session list so a dropped stream is visible everywhere, not just mid-conversation.
43 *
44 * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
45 * when [onRetry] is supplied, so the user can re-connect without hunting for the
46 * chat screen's reconnect button or navigating to the server list. Transient
47 * connecting/reconnecting states remain non-interactive (the system is already trying).
48 */
49@Composable
50fun ConnectionBanner(
51 state: EventStreamClient.ConnectionState,
52 modifier: Modifier = Modifier,
53 isOnline: Boolean = true,
54 onRetry: (() -> Unit)? = null,
55) {
56 // When the device itself has no connectivity, surface that distinctly (instead of
57 // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
58 // credentials", neither of which reflects the real problem.
59 val text = if (!isOnline) {
60 stringResource(R.string.offline)
61 } else {
62 when (state) {
63 EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
64 EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
65 EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed_endpoint)
66 EventStreamClient.ConnectionState.AuthFailed -> stringResource(R.string.connection_failed)
67 EventStreamClient.ConnectionState.Connected -> null
68 }
69 }
70 val isOffline = !isOnline
71 // AnimatedVisibility so the banner slides/fades in and out instead of appearing
72 // and disappearing instantly — a state change that's especially jarring when the
73 // connection flaps, and was called out as a rough edge in the UX audit.
74 // Retain the last non-null banner text so the exit fade-out has content to render.
75 // The content lambda recomposes with the now-null `text` during the exit animation;
76 // early-returning on null there would leave nothing to fade, so the banner would just
77 // vanish instead of fading out.
78 val lastText = remember { mutableStateOf(text) }
79 // Write the retained text in a SideEffect (not directly in the body): writing state during
80 // composition is a documented anti-pattern that can trigger extra recompositions and isn't
81 // guaranteed a defined ordering. SideEffect runs only after a successful composition.
82 SideEffect { if (text != null) lastText.value = text }
83 AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
84 val shown = lastText.value ?: return@AnimatedVisibility
85 // Distinguish a hard failure (e.g. bad credentials) and an offline device from
86 // transient connecting/reconnecting states by switching to the error palette,
87 // so the banner conveys urgency without relying on text alone.
88 val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed ||
89 state == EventStreamClient.ConnectionState.AuthFailed
90 // Server-side failure (auth or endpoint) — distinct from offline. Used to gate the
91 // inline Retry button, which only makes sense when the device is online and the server
92 // rejected the connection (not when the device itself is offline).
93 val isServerFailure = state == EventStreamClient.ConnectionState.Failed ||
94 state == EventStreamClient.ConnectionState.AuthFailed
95 val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
96 else MaterialTheme.colorScheme.tertiaryContainer
97 val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
98 else MaterialTheme.colorScheme.onTertiaryContainer
99 Surface(
100 modifier = modifier
101 .fillMaxWidth()
102 // mergeDescendants so the spinner + label read as one TalkBack stop instead of
103 // several; the Retry TextButton is its own merged node, so it stays separately
104 // actionable.
105 .semantics(mergeDescendants = true) {
106 // Announce connection state changes to TalkBack users so they're
107 // aware the stream dropped/reconnecting without visual cues.
108 liveRegion = LiveRegionMode.Polite
109 contentDescription = shown
110 },
111 color = container,
112 ) {
113 Row(
114 modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 8.dp),
115 horizontalArrangement = Arrangement.Center,
116 verticalAlignment = Alignment.CenterVertically,
117 ) {
118 if (!isFailed) {
119 CircularProgressIndicator(
120 Modifier.size(16.dp),
121 strokeWidth = 2.dp,
122 )
123 }
124 Text(
125 shown,
126 style = MaterialTheme.typography.labelMedium,
127 color = onContainer,
128 modifier = Modifier
129 .padding(start = 6.dp)
130 .weight(1f, fill = false),
131 )
132 // Inline retry on the Failed banner so the user can recover without
133 // leaving the screen. Hidden during transient states (the system is
134 // already reconnecting), when offline (retry can't help), and when no
135 // callback is wired (callers that don't have a reconnect path).
136 if (isServerFailure && !isOffline && onRetry != null) {
137 TextButton(
138 onClick = onRetry,
139 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
140 modifier = Modifier.defaultMinSize(minHeight = 48.dp),
141 ) {
142 Text(stringResource(R.string.retry_now), color = onContainer)
143 }
144 }
145 }
146 }
147 }
150/**
151 * Convenience wrapper that collects the SSE connection state and device connectivity from
152 * [container] and renders a [ConnectionBanner] aligned to the top center of the calling
153 * [BoxScope]. Screens that don't already expose connection state (Files, Settings,
154 * Diagnostics) can call this instead of duplicating the flatMapLatest boilerplate.
155 * The retry callback triggers an SSE reconnect on the active connection.
156 */
157@OptIn(ExperimentalCoroutinesApi::class)
158@Composable
159fun BoxScope.ConnectionBannerFor(container: AppContainer) {
160 // flatMapLatest without stateIn: collectAsStateWithLifecycle handles the lifecycle and
161 // gives an initial value. No ViewModel scope needed, so this works on any screen.
162 val connectionState by produceState(
163 initialValue = EventStreamClient.ConnectionState.Disconnected,
164 container,
165 ) {
166 container.activeConnection
167 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
168 .collect { value = it }
169 }
170 val isOnline by container.isOnline.collectAsStateWithLifecycle()
171 ConnectionBanner(
172 state = connectionState,
173 modifier = Modifier.align(Alignment.TopCenter),
174 isOnline = isOnline,
175 onRetry = { container.activeConnection.value?.events?.triggerReconnect() },
176 )