feat(android): 4-state connection banner with unstable + back-online flash
android / Build + lint + test (push) Successful in 3m27s
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:
+80
-43
@@ -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 -> ""
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user