feat: add connection status indicator (Issue #28)

Adds a colored dot next to the '⚡ Hermes' brand header in the
navigation drawer:
  - Green: connected
  - Yellow: connecting / reconnecting
  - Red: disconnected

Uses a StateFlow on HermesWsClient that tracks the WebSocket
connection lifecycle and is collected in the drawer header.
This commit is contained in:
M57
2026-06-19 12:07:44 +03:00
parent 0fa61148a3
commit 0038606c05
2 changed files with 63 additions and 12 deletions
@@ -1,5 +1,8 @@
package com.m57.hermescontrol 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.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize 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.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding 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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
@@ -39,12 +45,15 @@ import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -53,6 +62,8 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.m57.hermescontrol.data.local.AuthManager 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 kotlinx.coroutines.launch
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
@@ -188,16 +199,34 @@ fun MainNavigation() {
ModalDrawerSheet( ModalDrawerSheet(
modifier = Modifier.verticalScroll(rememberScrollState()), modifier = Modifier.verticalScroll(rememberScrollState()),
) { ) {
// Brand header // Brand header with connection status
Text( val connectionStatus by HermesWsClient.connectionStatus.collectAsState()
text = "⚡ Hermes", 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), modifier = Modifier.padding(start = 20.dp, top = 20.dp, end = 16.dp, bottom = 4.dp),
style = ) {
MaterialTheme.typography.headlineSmall.copy( Text(
fontWeight = FontWeight.Bold, text = "⚡ Hermes",
), style =
color = MaterialTheme.colorScheme.primary, 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(
text = "AI Agent Control", text = "AI Agent Control",
modifier = Modifier.padding(start = 20.dp, bottom = 12.dp, end = 16.dp), modifier = Modifier.padding(start = 20.dp, bottom = 12.dp, end = 16.dp),
@@ -10,8 +10,11 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -22,6 +25,16 @@ import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger 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. * 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. */ /** Collect this from ViewModels to receive all parsed [WsEvent]s. */
val events: SharedFlow<WsEvent> = _events.asSharedFlow() val events: SharedFlow<WsEvent> = _events.asSharedFlow()
// ── Connection status flow ──────────────────────────────────────────
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
/** Observable connection status: DISCONNECTED / CONNECTING / CONNECTED / RECONNECTING */
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
// ── Callbacks (legacy / convenience) ───────────────────────────────── // ── Callbacks (legacy / convenience) ─────────────────────────────────
var onMessage: ((WsEvent) -> Unit)? = null var onMessage: ((WsEvent) -> Unit)? = null
@@ -93,20 +112,19 @@ object HermesWsClient {
} }
intentionalClose.set(false) intentionalClose.set(false)
currentBackoff = INITIAL_BACKOFF_MS currentBackoff = INITIAL_BACKOFF_MS
_connectionStatus.value = ConnectionStatus.CONNECTING
openSocket() openSocket()
} }
/** Cleanly close the WebSocket and stop auto-reconnect. */ /** Cleanly close the WebSocket and stop auto-reconnect. */
fun disconnect() { fun disconnect() {
intentionalClose.set(true) 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?.cancel()
reconnectJob = null reconnectJob = null
webSocket?.close(1000, "Client closed") webSocket?.close(1000, "Client closed")
webSocket = null webSocket = null
connected.set(false) connected.set(false)
_connectionStatus.value = ConnectionStatus.DISCONNECTED
} }
// ── Send helpers ───────────────────────────────────────────────────── // ── Send helpers ─────────────────────────────────────────────────────
@@ -156,6 +174,7 @@ object HermesWsClient {
if (intentionalClose.get()) return if (intentionalClose.get()) return
if (!AuthManager.isAutoReconnect()) { if (!AuthManager.isAutoReconnect()) {
if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled") if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled")
_connectionStatus.value = ConnectionStatus.DISCONNECTED
return return
} }
val delay = currentBackoff val delay = currentBackoff
@@ -205,6 +224,7 @@ object HermesWsClient {
) { ) {
Log.i(TAG, "WebSocket opened") Log.i(TAG, "WebSocket opened")
connected.set(true) connected.set(true)
_connectionStatus.value = ConnectionStatus.CONNECTED
currentBackoff = INITIAL_BACKOFF_MS currentBackoff = INITIAL_BACKOFF_MS
onConnected?.invoke() onConnected?.invoke()
} }
@@ -244,6 +264,7 @@ object HermesWsClient {
Log.i(TAG, "WebSocket closed: $code $reason") Log.i(TAG, "WebSocket closed: $code $reason")
connected.set(false) connected.set(false)
onDisconnected?.invoke(reason) onDisconnected?.invoke(reason)
_connectionStatus.value = ConnectionStatus.RECONNECTING
scheduleReconnect() scheduleReconnect()
} }
@@ -256,6 +277,7 @@ object HermesWsClient {
connected.set(false) connected.set(false)
onError?.invoke(t) onError?.invoke(t)
onDisconnected?.invoke(t.message) onDisconnected?.invoke(t.message)
_connectionStatus.value = ConnectionStatus.RECONNECTING
scheduleReconnect() scheduleReconnect()
} }
} }