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:
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user