feat: connection status indicator in drawer (Issue #28)

feat: connection status indicator in drawer (Issue #28)
This commit is contained in:
M57 authored and GitHub committed 2026-06-19 12:40:32 +03:00
commit 66da56ca24
2 files changed
+65 -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
@@ -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),
@@ -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()
}
}