mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-27 20:45:49 +00:00
feat(android): add v2 nodes devices settings
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user