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 committed 2026-06-19 12:07:44 +03:00
1 parent 0fa61148a3
commit 0038606c05
2 files changed
+63 -12

No files matched your search

@@ -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
@@ -39,12 +45,15 @@ import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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,34 @@ 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),
@@ -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<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) ─────────────────────────────────
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()
}
}