feat: connection status indicator in drawer (Issue #28)
feat: connection status indicator in drawer (Issue #28)
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user