feat(android): add v2 nodes devices settings

This commit is contained in:
Ayaan Zaidi
2026-05-19 20:30:04 +05:30
parent 817ca4bf65
commit e067203b22
5 changed files with 505 additions and 0 deletions

View File

@@ -97,6 +97,10 @@ class MainViewModel(
val skillsSummary: StateFlow<GatewaySkillsSummary> = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary }
val skillsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillsRefreshing }
val skillsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillsErrorText }
val nodesDevicesSummary: StateFlow<GatewayNodesDevicesSummary> =
runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary }
val nodesDevicesRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.nodesDevicesRefreshing }
val nodesDevicesErrorText: StateFlow<String?> = runtimeState(initial = null) { it.nodesDevicesErrorText }
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
@@ -391,6 +395,10 @@ class MainViewModel(
ensureRuntime().refreshSkills()
}
fun refreshNodesDevices() {
ensureRuntime().refreshNodesDevices()
}
fun loadChat(sessionKey: String) {
ensureRuntime().loadChat(sessionKey)
}

View File

@@ -329,6 +329,19 @@ class NodeRuntime(
val skillsRefreshing: StateFlow<Boolean> = _skillsRefreshing.asStateFlow()
private val _skillsErrorText = MutableStateFlow<String?>(null)
val skillsErrorText: StateFlow<String?> = _skillsErrorText.asStateFlow()
private val _nodesDevicesSummary =
MutableStateFlow(
GatewayNodesDevicesSummary(
nodes = emptyList(),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
)
val nodesDevicesSummary: StateFlow<GatewayNodesDevicesSummary> = _nodesDevicesSummary.asStateFlow()
private val _nodesDevicesRefreshing = MutableStateFlow(false)
val nodesDevicesRefreshing: StateFlow<Boolean> = _nodesDevicesRefreshing.asStateFlow()
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
@@ -374,6 +387,12 @@ class NodeRuntime(
_cronJobs.value = emptyList()
_usageSummary.value = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())
_skillsSummary.value = GatewaySkillsSummary(skills = emptyList())
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = emptyList(),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
chat.applyMainSessionKey(resolveMainSessionKey())
chat.onDisconnected(message)
updateStatus()
@@ -643,6 +662,7 @@ class NodeRuntime(
refreshCronFromGateway()
refreshUsageFromGateway()
refreshSkillsFromGateway()
refreshNodesDevicesFromGateway()
}
}
@@ -676,6 +696,12 @@ class NodeRuntime(
}
}
fun refreshNodesDevices() {
scope.launch {
refreshNodesDevicesFromGateway()
}
}
fun requestCanvasRehydrate(
source: String = "manual",
force: Boolean = true,
@@ -1699,6 +1725,43 @@ class NodeRuntime(
}
}
private suspend fun refreshNodesDevicesFromGateway() {
_nodesDevicesRefreshing.value = true
_nodesDevicesErrorText.value = null
if (!operatorConnected) {
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = emptyList(),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
_nodesDevicesRefreshing.value = false
return
}
try {
val nodesRes = operatorSession.request("node.list", "{}")
val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull()
val devicesRoot =
try {
val devicesRes = operatorSession.request("device.pair.list", "{}")
json.parseToJsonElement(devicesRes).asObjectOrNull()
} catch (_: Throwable) {
null
}
_nodesDevicesSummary.value =
GatewayNodesDevicesSummary(
nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray),
pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray),
pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray),
devicePairingAvailable = devicesRoot != null,
)
} catch (_: Throwable) {
_nodesDevicesErrorText.value = "Could not load nodes and devices."
} finally {
_nodesDevicesRefreshing.value = false
}
}
private fun parseGatewayModels(models: JsonArray?): List<GatewayModelSummary> =
models
?.mapNotNull { item ->
@@ -1809,6 +1872,80 @@ class NodeRuntime(
private fun skillMissingCount(missing: JsonObject?): Int = listOf("bins", "env", "config", "os").sumOf { key -> (missing?.get(key) as? JsonArray)?.size ?: 0 }
private fun parseGatewayNodes(nodes: JsonArray?): List<GatewayNodeSummary> =
nodes
?.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return@mapNotNull null
GatewayNodeSummary(
id = id,
displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
paired = obj.boolean("paired"),
connected = obj.boolean("connected"),
capabilities = parseStringArray(obj["caps"] as? JsonArray),
commands = parseStringArray(obj["commands"] as? JsonArray),
)
}.orEmpty()
private fun parsePendingDevices(devices: JsonArray?): List<GatewayPendingDeviceSummary> =
devices
?.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val requestId = obj["requestId"].asStringOrNull()?.trim().orEmpty()
val deviceId = obj["deviceId"].asStringOrNull()?.trim().orEmpty()
if (requestId.isEmpty() || deviceId.isEmpty()) return@mapNotNull null
GatewayPendingDeviceSummary(
requestId = requestId,
deviceId = deviceId,
displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
roles = parseStringArray(obj["roles"] as? JsonArray),
scopes = parseStringArray(obj["scopes"] as? JsonArray),
requestedAtMs = obj.long("ts"),
repair = obj.boolean("isRepair"),
)
}.orEmpty()
private fun parsePairedDevices(devices: JsonArray?): List<GatewayPairedDeviceSummary> =
devices
?.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val deviceId = obj["deviceId"].asStringOrNull()?.trim().orEmpty()
if (deviceId.isEmpty()) return@mapNotNull null
GatewayPairedDeviceSummary(
deviceId = deviceId,
displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
roles = parseStringArray(obj["roles"] as? JsonArray),
scopes = parseStringArray(obj["scopes"] as? JsonArray),
tokens = parseDeviceTokens(obj["tokens"] as? JsonArray),
approvedAtMs = obj.long("approvedAtMs"),
)
}.orEmpty()
private fun parseDeviceTokens(tokens: JsonArray?): List<GatewayDeviceTokenSummary> =
tokens
?.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val role = obj["role"].asStringOrNull()?.trim().orEmpty()
if (role.isEmpty()) return@mapNotNull null
GatewayDeviceTokenSummary(
role = role,
scopes = parseStringArray(obj["scopes"] as? JsonArray),
revoked = obj.long("revokedAtMs") != null,
updatedAtMs = obj.long("rotatedAtMs") ?: obj.long("createdAtMs") ?: obj.long("lastUsedAtMs"),
)
}.orEmpty()
private fun parseStringArray(items: JsonArray?): List<String> =
items
?.mapNotNull { item -> item.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } }
.orEmpty()
private fun cronScheduleLabel(schedule: JsonObject?): String =
when (schedule?.get("kind").asStringOrNull()) {
"at" -> "One time"
@@ -2136,6 +2273,53 @@ data class GatewaySkillSummary(
val installCount: Int,
)
data class GatewayNodesDevicesSummary(
val nodes: List<GatewayNodeSummary>,
val pendingDevices: List<GatewayPendingDeviceSummary>,
val pairedDevices: List<GatewayPairedDeviceSummary>,
val devicePairingAvailable: Boolean = true,
)
data class GatewayNodeSummary(
val id: String,
val displayName: String?,
val remoteIp: String?,
val version: String?,
val deviceFamily: String?,
val paired: Boolean,
val connected: Boolean,
val capabilities: List<String>,
val commands: List<String>,
)
data class GatewayPendingDeviceSummary(
val requestId: String,
val deviceId: String,
val displayName: String?,
val remoteIp: String?,
val roles: List<String>,
val scopes: List<String>,
val requestedAtMs: Long?,
val repair: Boolean,
)
data class GatewayPairedDeviceSummary(
val deviceId: String,
val displayName: String?,
val remoteIp: String?,
val roles: List<String>,
val scopes: List<String>,
val tokens: List<GatewayDeviceTokenSummary>,
val approvedAtMs: Long?,
)
data class GatewayDeviceTokenSummary(
val role: String,
val scopes: List<String>,
val revoked: Boolean,
val updatedAtMs: Long?,
)
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toDoubleOrNull()

View File

@@ -0,0 +1,288 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayDeviceTokenSummary
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPairedDeviceSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
internal fun V2NodesDevicesSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val summary by viewModel.nodesDevicesSummary.collectAsState()
val refreshing by viewModel.nodesDevicesRefreshing.collectAsState()
val errorText by viewModel.nodesDevicesErrorText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshNodesDevices()
}
}
V2SettingsDetailFrame(
title = "Nodes & Devices",
subtitle = "Live nodes, paired phones, and pending device requests.",
icon = Icons.Default.Cloud,
onBack = onBack,
) {
V2SettingsMetricPanel(
rows =
listOf(
V2SettingsMetric("Nodes", summary.nodes.size.toString()),
V2SettingsMetric("Online", summary.nodes.count { it.connected }.toString()),
V2SettingsMetric("Devices", if (summary.devicePairingAvailable) summary.pairedDevices.size.toString() else "Locked"),
V2SettingsMetric("Pending", summary.pendingDevices.size.toString()),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(
text = if (refreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshNodesDevices,
enabled = isConnected && !refreshing,
modifier = Modifier.weight(1f),
)
}
errorText?.let {
ClawPanel {
Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
when {
!isConnected ->
ClawPanel {
Text(text = "Connect the gateway to load nodes and paired devices.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
summary.isEmpty() ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No nodes or paired devices.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Linked phones and node hosts will appear here after pairing.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> V2NodesDevicesPanel(summary = summary)
}
}
}
@Composable
private fun V2NodesDevicesPanel(summary: GatewayNodesDevicesSummary) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (!summary.devicePairingAvailable) {
ClawPanel {
Text(text = "Pairing controls are not available from this connection.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
if (summary.pendingDevices.isNotEmpty()) {
V2NodesSection(title = "Pending Requests") {
summary.pendingDevices.forEachIndexed { index, device ->
V2PendingDeviceRow(device = device)
if (index != summary.pendingDevices.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
if (summary.nodes.isNotEmpty()) {
V2NodesSection(title = "Nodes") {
summary.nodes.forEachIndexed { index, node ->
V2NodeRow(node = node)
if (index != summary.nodes.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
if (summary.pairedDevices.isNotEmpty()) {
V2NodesSection(title = "Paired Devices") {
summary.pairedDevices.forEachIndexed { index, device ->
V2PairedDeviceRow(device = device)
if (index != summary.pairedDevices.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
}
@Composable
private fun V2NodesSection(
title: String,
content: @Composable () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
content()
}
}
}
}
@Composable
private fun V2NodeRow(node: GatewayNodeSummary) {
V2DeviceListRow(
badge = nodeBadge(node.displayName ?: node.id),
title = node.displayName ?: node.id,
subtitle = nodeSubtitle(node),
statusText = if (node.connected) "Online" else "Offline",
status = if (node.connected) ClawStatus.Success else ClawStatus.Warning,
)
}
@Composable
private fun V2PendingDeviceRow(device: GatewayPendingDeviceSummary) {
V2DeviceListRow(
badge = nodeBadge(device.displayName ?: device.deviceId),
title = device.displayName ?: "New device",
subtitle = pendingDeviceSubtitle(device),
statusText = if (device.repair) "Repair" else "Review",
status = ClawStatus.Warning,
)
}
@Composable
private fun V2PairedDeviceRow(device: GatewayPairedDeviceSummary) {
V2DeviceListRow(
badge = nodeBadge(device.displayName ?: device.deviceId),
title = device.displayName ?: "Paired device",
subtitle = pairedDeviceSubtitle(device),
statusText = pairedDeviceStatusText(device.tokens),
status = pairedDeviceStatus(device.tokens),
)
}
@Composable
private fun V2DeviceListRow(
badge: String,
title: String,
subtitle: String,
statusText: String,
status: ClawStatus,
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Surface(
modifier = Modifier.size(30.dp),
shape = CircleShape,
color = ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, ClawTheme.colors.border),
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Text(text = badge, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
ClawStatusPill(text = statusText, status = status)
}
}
private fun GatewayNodesDevicesSummary.isEmpty(): Boolean = nodes.isEmpty() && pendingDevices.isEmpty() && pairedDevices.isEmpty()
private fun nodeSubtitle(node: GatewayNodeSummary): String {
val kind = node.deviceFamily ?: "Node host"
val version = node.version?.let { "OpenClaw $it" }
val status = if (node.paired) "Paired" else "Unpaired"
val commands =
node.commands
.take(2)
.joinToString(", ")
.takeIf { it.isNotBlank() }
return listOfNotNull(kind, version, status, commands).joinToString(" · ")
}
private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String {
val roles = formatDeviceList(device.roles, "role")
val scopes = formatDeviceList(device.scopes, "scope")
val requested = device.requestedAtMs?.let { "requested ${relativeDeviceTime(it)}" }
return listOfNotNull(roles, scopes, requested, device.remoteIp).joinToString(" · ")
}
private fun pairedDeviceSubtitle(device: GatewayPairedDeviceSummary): String {
val roles = formatDeviceList(device.roles, "role")
val scopes = formatDeviceList(device.scopes, "scope")
val tokens = "${device.tokens.count { !it.revoked }}/${device.tokens.size} active tokens"
return listOfNotNull(roles, scopes, tokens, device.remoteIp).joinToString(" · ")
}
private fun pairedDeviceStatusText(tokens: List<GatewayDeviceTokenSummary>): String =
when {
tokens.isEmpty() -> "Paired"
tokens.any { !it.revoked } -> "Active"
else -> "Needs Token"
}
private fun pairedDeviceStatus(tokens: List<GatewayDeviceTokenSummary>): ClawStatus =
when {
tokens.isEmpty() -> ClawStatus.Neutral
tokens.any { !it.revoked } -> ClawStatus.Success
else -> ClawStatus.Warning
}
private fun formatDeviceList(
values: List<String>,
fallback: String,
): String? =
when (values.size) {
0 -> null
1 -> values.first()
else -> "${values.size} ${fallback}s"
}
private fun nodeBadge(value: String): String =
value
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.joinToString("")
.ifBlank { "N" }
private fun relativeDeviceTime(timeMs: Long): String {
val minutes = ((System.currentTimeMillis() - timeMs).coerceAtLeast(0L)) / 60_000L
if (minutes < 1) return "now"
if (minutes < 60) return "${minutes}m ago"
val hours = minutes / 60L
if (hours < 24) return "${hours}h ago"
return "${hours / 24L}d ago"
}

View File

@@ -76,6 +76,7 @@ internal enum class V2SettingsRoute {
CronJobs,
Usage,
Skills,
NodesDevices,
Notifications,
PhoneCapabilities,
Gateway,
@@ -99,6 +100,7 @@ internal fun V2SettingsDetailScreen(
V2SettingsRoute.CronJobs -> V2CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.Usage -> V2UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.Skills -> V2SkillsSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.NodesDevices -> V2NodesDevicesSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.Notifications -> V2NotificationSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.PhoneCapabilities -> V2PhoneCapabilitiesScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.Gateway -> V2GatewaySettingsScreen(viewModel = viewModel, onBack = onBack)

View File

@@ -1,6 +1,7 @@
package ai.openclaw.app.ui
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewaySkillSummary
import ai.openclaw.app.HomeDestination
import ai.openclaw.app.MainViewModel
@@ -525,6 +526,7 @@ private fun V2SettingsShellScreen(
val cronStatus by viewModel.cronStatus.collectAsState()
val usageSummary by viewModel.usageSummary.collectAsState()
val skillsSummary by viewModel.skillsSummary.collectAsState()
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
var route by rememberSaveable { mutableStateOf(V2SettingsRoute.Home) }
LaunchedEffect(isConnected) {
@@ -533,6 +535,7 @@ private fun V2SettingsShellScreen(
viewModel.refreshCronJobs()
viewModel.refreshUsage()
viewModel.refreshSkills()
viewModel.refreshNodesDevices()
}
}
@@ -573,6 +576,7 @@ private fun V2SettingsShellScreen(
V2SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = V2SettingsRoute.CronJobs),
V2SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = V2SettingsRoute.Usage),
V2SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = V2SettingsRoute.Skills),
V2SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = V2SettingsRoute.NodesDevices),
V2SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = V2SettingsRoute.Notifications),
V2SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = V2SettingsRoute.PhoneCapabilities),
V2SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = V2SettingsRoute.Gateway),
@@ -648,6 +652,25 @@ private fun skillsStatus(skills: List<GatewaySkillSummary>): Boolean? =
else -> true
}
private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String {
val online = summary.nodes.count { it.connected }
val devices = summary.pairedDevices.size
return when {
summary.pendingDevices.isNotEmpty() -> "${summary.pendingDevices.size} pending"
summary.nodes.isNotEmpty() -> "$online/${summary.nodes.size} online"
devices > 0 -> "$devices paired"
else -> "No devices"
}
}
private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? =
when {
summary.pendingDevices.isNotEmpty() -> false
summary.nodes.any { it.connected } -> true
summary.pairedDevices.isNotEmpty() -> true
else -> null
}
private data class V2SettingsRow(
val title: String,
val value: String,