From 8b77c6be97b51d41f0716ffde53ba153c9fffdfd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 5 Jun 2026 12:23:55 -0400 Subject: [PATCH] feat(android): 4-state connection banner with unstable + back-online flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banner VM collects NetworkStatusController.state directly (drops the redundant WhileSubscribed re-wrap). Adds a mild 'Reconnecting…' treatment for the non-gating Unstable state and a transient 'Back online' confirmation on down→Healthy recovery (try/finally guards against a stuck flash). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../connectivity/ui/ConnectionErrorBanner.kt | 123 ++++++++++++------ 1 file changed, 80 insertions(+), 43 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt index c360ab84..2d1eef2f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt @@ -14,84 +14,121 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope import com.composables.icons.lucide.CloudOff import com.composables.icons.lucide.Lucide import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.ServerHealth import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn import javax.inject.Inject -private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L +private const val BACK_ONLINE_FLASH_MS = 2_000L /** - * Lifts [NetworkStatusController]'s tri-state into a StateFlow for the banner - * composable. Keeps the banner pure-presentation. + * Exposes [NetworkStatusController]'s tri-state directly to the banner. No + * re-wrapping StateFlow — the controller's is already app-scoped and warm. */ @HiltViewModel class ConnectivityBannerViewModel @Inject constructor( - health: NetworkStatusController, + networkStatus: NetworkStatusController, @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, ) : ViewModel() { - val health: StateFlow = health.state.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS), - initialValue = ServerHealth.Healthy, - ) + val health: StateFlow = networkStatus.state } /** - * Banner shown at the top of the shell when the user can't reach the server. - * Tri-state so we tell the user *why*: no device network vs server-down. - * Copy choices match the Flutter analogues. Auto-hides via slide+fade when - * health returns to [ServerHealth.Healthy]. + * Shell banner. Tells the user *why* they're degraded — no link vs server-down + * — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable] + * window, and a brief "Back online" confirmation when health recovers so + * recovery is unmistakable. Sentence case, understated voice (design system). */ @Composable fun ConnectionErrorBanner( viewModel: ConnectivityBannerViewModel = hiltViewModel(), ) { val health by viewModel.health.collectAsStateWithLifecycle() + var showBackOnline by remember { mutableStateOf(false) } + var wasDown by remember { mutableStateOf(false) } + + LaunchedEffect(health) { + val down = health == ServerHealth.Offline || health == ServerHealth.ServerDown + if (health == ServerHealth.Healthy && wasDown) { + // try/finally so a mid-delay cancellation (health flips again) can't + // orphan the flag and leave "Back online" stuck on screen. + try { + showBackOnline = true + delay(BACK_ONLINE_FLASH_MS) + } finally { + showBackOnline = false + } + } + wasDown = down + } + AnimatedVisibility( - visible = health != ServerHealth.Healthy, + visible = health != ServerHealth.Healthy || showBackOnline, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.errorContainer) - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Lucide.CloudOff, - contentDescription = null, - tint = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - text = when (health) { - ServerHealth.Offline -> - "No connection — check Wi-Fi or mobile data." - ServerHealth.ServerDown -> - "Server unreachable — your cached content is still available." - ServerHealth.Unstable -> "Reconnecting…" - ServerHealth.Healthy -> "" - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - } + BannerContent(health = health, backOnline = showBackOnline) } } + +@Composable +private fun BannerContent(health: ServerHealth, backOnline: Boolean) { + val scheme = MaterialTheme.colorScheme + val background: Color + val foreground: Color + when { + backOnline -> { + background = scheme.secondaryContainer + foreground = scheme.onSecondaryContainer + } + health == ServerHealth.Unstable -> { + background = scheme.surfaceVariant + foreground = scheme.onSurfaceVariant + } + else -> { + background = scheme.errorContainer + foreground = scheme.onErrorContainer + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .background(background) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(imageVector = Lucide.CloudOff, contentDescription = null, tint = foreground) + Text( + text = bannerText(health, backOnline), + style = MaterialTheme.typography.bodyMedium, + color = foreground, + ) + } +} + +private fun bannerText(health: ServerHealth, backOnline: Boolean): String = when { + backOnline -> "Back online." + health == ServerHealth.Offline -> "No connection — check Wi-Fi or mobile data." + health == ServerHealth.ServerDown -> + "Server unreachable — your cached content is still available." + health == ServerHealth.Unstable -> "Reconnecting…" + else -> "" +}