diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index f5b8346c..0dee65c4 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -1,5 +1,8 @@ package com.m57.hermescontrol +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -7,7 +10,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat @@ -41,10 +47,13 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -53,6 +62,8 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.ws.ConnectionStatus +import com.m57.hermescontrol.data.ws.HermesWsClient import kotlinx.coroutines.launch import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent @@ -188,16 +199,36 @@ fun MainNavigation() { ModalDrawerSheet( modifier = Modifier.verticalScroll(rememberScrollState()), ) { - // Brand header - Text( - text = "⚡ Hermes", + // Brand header with connection status + val connectionStatus by HermesWsClient.connectionStatus.collectAsState() + val statusColor = + when (connectionStatus) { + ConnectionStatus.CONNECTED -> Color(0xFF4CAF50) // green + ConnectionStatus.CONNECTING, + ConnectionStatus.RECONNECTING, + -> Color(0xFFFFC107) // yellow + ConnectionStatus.DISCONNECTED -> Color(0xFFF44336) // red + } + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 20.dp, top = 20.dp, end = 16.dp, bottom = 4.dp), - style = - MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold, - ), - color = MaterialTheme.colorScheme.primary, - ) + ) { + Text( + text = "⚡ Hermes", + style = + MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = + Modifier + .size(10.dp) + .background(color = statusColor, shape = CircleShape), + ) + } Text( text = "AI Agent Control", modifier = Modifier.padding(start = 20.dp, bottom = 12.dp, end = 16.dp), diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 650c2e47..f178fa67 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -10,8 +10,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request @@ -22,6 +25,16 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +/** + * Connection status for the WebSocket client. + */ +enum class ConnectionStatus { + DISCONNECTED, + CONNECTING, + CONNECTED, + RECONNECTING, +} + /** * WebSocket client for the Hermes Dashboard JSON-RPC 2.0 interface. * @@ -74,6 +87,12 @@ object HermesWsClient { /** Collect this from ViewModels to receive all parsed [WsEvent]s. */ val events: SharedFlow = _events.asSharedFlow() + // ── Connection status flow ────────────────────────────────────────── + private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) + + /** Observable connection status: DISCONNECTED / CONNECTING / CONNECTED / RECONNECTING */ + val connectionStatus: StateFlow = _connectionStatus.asStateFlow() + // ── Callbacks (legacy / convenience) ───────────────────────────────── var onMessage: ((WsEvent) -> Unit)? = null @@ -93,20 +112,19 @@ object HermesWsClient { } intentionalClose.set(false) currentBackoff = INITIAL_BACKOFF_MS + _connectionStatus.value = ConnectionStatus.CONNECTING openSocket() } /** Cleanly close the WebSocket and stop auto-reconnect. */ fun disconnect() { intentionalClose.set(true) - // B4 (Jun 18 2026, kanban t_2b834d90): also cancel any pending - // reconnect coroutine so a scheduled openSocket() can't fire after - // the user explicitly disconnected. reconnectJob?.cancel() reconnectJob = null webSocket?.close(1000, "Client closed") webSocket = null connected.set(false) + _connectionStatus.value = ConnectionStatus.DISCONNECTED } // ── Send helpers ───────────────────────────────────────────────────── @@ -156,6 +174,7 @@ object HermesWsClient { if (intentionalClose.get()) return if (!AuthManager.isAutoReconnect()) { if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled") + _connectionStatus.value = ConnectionStatus.DISCONNECTED return } val delay = currentBackoff @@ -205,6 +224,7 @@ object HermesWsClient { ) { Log.i(TAG, "WebSocket opened") connected.set(true) + _connectionStatus.value = ConnectionStatus.CONNECTED currentBackoff = INITIAL_BACKOFF_MS onConnected?.invoke() } @@ -244,6 +264,7 @@ object HermesWsClient { Log.i(TAG, "WebSocket closed: $code $reason") connected.set(false) onDisconnected?.invoke(reason) + _connectionStatus.value = ConnectionStatus.RECONNECTING scheduleReconnect() } @@ -256,6 +277,7 @@ object HermesWsClient { connected.set(false) onError?.invoke(t) onDisconnected?.invoke(t.message) + _connectionStatus.value = ConnectionStatus.RECONNECTING scheduleReconnect() } }