feat(android): 4-state connection banner with unstable + back-online flash
android / Build + lint + test (push) Successful in 3m27s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 12:23:55 -04:00
parent c33ef18a50
commit 8b77c6be97
@@ -14,84 +14,121 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.composables.icons.lucide.CloudOff import com.composables.icons.lucide.CloudOff
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.connectivity.NetworkStatusController import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject 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 * Exposes [NetworkStatusController]'s tri-state directly to the banner. No
* composable. Keeps the banner pure-presentation. * re-wrapping StateFlow — the controller's is already app-scoped and warm.
*/ */
@HiltViewModel @HiltViewModel
class ConnectivityBannerViewModel @Inject constructor( class ConnectivityBannerViewModel @Inject constructor(
health: NetworkStatusController, networkStatus: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle, @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val health: StateFlow<ServerHealth> = health.state.stateIn( val health: StateFlow<ServerHealth> = networkStatus.state
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
initialValue = ServerHealth.Healthy,
)
} }
/** /**
* Banner shown at the top of the shell when the user can't reach the server. * Shell banner. Tells the user *why* they're degraded — no link vs server-down
* Tri-state so we tell the user *why*: no device network vs server-down. * — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
* Copy choices match the Flutter analogues. Auto-hides via slide+fade when * window, and a brief "Back online" confirmation when health recovers so
* health returns to [ServerHealth.Healthy]. * recovery is unmistakable. Sentence case, understated voice (design system).
*/ */
@Composable @Composable
fun ConnectionErrorBanner( fun ConnectionErrorBanner(
viewModel: ConnectivityBannerViewModel = hiltViewModel(), viewModel: ConnectivityBannerViewModel = hiltViewModel(),
) { ) {
val health by viewModel.health.collectAsStateWithLifecycle() 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( AnimatedVisibility(
visible = health != ServerHealth.Healthy, visible = health != ServerHealth.Healthy || showBackOnline,
enter = expandVertically() + fadeIn(), enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(), exit = shrinkVertically() + fadeOut(),
) { ) {
Row( BannerContent(health = health, backOnline = showBackOnline)
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,
)
}
} }
} }
@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 -> ""
}