diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/connection/ConnectionStatusViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/connection/ConnectionStatusViewModel.kt new file mode 100644 index 0000000..edf0d6d --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/features/connection/ConnectionStatusViewModel.kt @@ -0,0 +1,98 @@ +package com.vaultdrop.mobile.features.connection + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vaultdrop.mobile.auth.TokenProvider +import com.vaultdrop.mobile.domain.ServerConfigStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** Statut de connexion au serveur affiché dans le header. */ +sealed interface ServerConnectionStatus { + /** Ping en cours (premier résultat pas encore reçu). */ + data object Checking : ServerConnectionStatus + data object Online : ServerConnectionStatus + data object Offline : ServerConnectionStatus + /** Mode local : aucun compte, pas de synchro serveur (statut volontaire). */ + data object Local : ServerConnectionStatus +} + +/** + * Surveille la joignabilité du serveur configuré (ping `/health`) et expose le + * résultat pour le badge du header. Scope Activity (créé dans [VaultDropApp]), + * comme [SyncViewModel]. + * + * - ne ping que lorsqu'un compte est connecté ([TokenProvider.current] non nul), + * sinon le statut reste `Local` ; + * - boucle périodique + re-test immédiat quand l'URL de base change ; + * - [checkNow] relance une vérification à la demande (appui sur le badge). + */ +@HiltViewModel +class ConnectionStatusViewModel @Inject constructor( + private val serverConfigStore: ServerConfigStore, + private val tokenProvider: TokenProvider, +) : ViewModel() { + + private val _status = MutableStateFlow(ServerConnectionStatus.Checking) + val status: StateFlow = _status.asStateFlow() + + private var baseUrlJob: Job? = null + private var loopJob: Job? = null + + /** Démarre le moniteur une seule fois (idempotent). */ + fun start() { + if (loopJob?.isActive == true) { + // Bascule sign-in/out : ré-évaluer tout de suite. + checkNow() + return + } + baseUrlJob = viewModelScope.launch { + serverConfigStore.baseUrl + .drop(1) + .distinctUntilChanged() + .collectLatest { performCheck() } + } + loopJob = viewModelScope.launch { + while (isActive) { + performCheck() + delay(CHECK_INTERVAL_MS) + } + } + } + + /** Vérification immédiate (appui sur le badge). */ + fun checkNow() { + viewModelScope.launch { performCheck() } + } + + private suspend fun performCheck() { + if (tokenProvider.current == null) { + _status.value = ServerConnectionStatus.Local + return + } + _status.value = ServerConnectionStatus.Checking + _status.value = serverConfigStore.checkHealth(serverConfigStore.current).fold( + onSuccess = { ServerConnectionStatus.Online }, + onFailure = { e -> + Timber.d(e, "connection: ping /health échoué") + ServerConnectionStatus.Offline + }, + ) + } + + companion object { + /** Cadence du ping — même cadence que la boucle de sync. */ + const val CHECK_INTERVAL_MS = 30_000L + } +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/ServerStatusBadge.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/ServerStatusBadge.kt new file mode 100644 index 0000000..f37eaa1 --- /dev/null +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/components/ServerStatusBadge.kt @@ -0,0 +1,74 @@ +package com.vaultdrop.mobile.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vaultdrop.mobile.R +import com.vaultdrop.mobile.features.connection.ServerConnectionStatus +import com.vaultdrop.mobile.ui.theme.InactiveGray + +private val OnlineGreen = Color(0xFF2E7D32) +private val CheckingAmber = Color(0xFFF9A825) + +/** + * Pilule de statut serveur affichée dans le header : cercle coloré + libellé. + * Un appui relance une vérification immédiate du serveur. + */ +@Composable +fun ServerStatusBadge( + status: ServerConnectionStatus, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val (dotColor, labelRes) = when (status) { + ServerConnectionStatus.Checking -> CheckingAmber to R.string.status_checking + ServerConnectionStatus.Online -> OnlineGreen to R.string.status_online + ServerConnectionStatus.Offline -> MaterialTheme.colorScheme.error to R.string.status_offline + ServerConnectionStatus.Local -> InactiveGray to R.string.status_local + } + val label = stringResource(labelRes) + val tapLabel = stringResource(R.string.status_tap_to_recheck) + val shape = RoundedCornerShape(percent = 50) + + Row( + modifier = modifier + .clip(shape) + .clickable( + role = Role.Button, + onClickLabel = tapLabel, + onClick = onClick, + ) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(dotColor, CircleShape), + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} \ No newline at end of file