From b996415ca9b97e1fd36ffbf9d812030d2dab6be2 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 24 Dec 2025 14:11:29 -0600
Subject: [PATCH] feat: Add ability to request telemetry from a remote node
(#4059)
---
.../geeksville/mesh/service/MeshService.kt | 31 +++
.../meshtastic/core/model/TelemetryType.kt | 26 +++
.../meshtastic/core/service/IMeshService.aidl | 5 +-
.../composeResources/values/strings.xml | 13 +-
.../node/component/AdministrationSection.kt | 183 ++++++++++-----
.../feature/node/component/CooldownButton.kt | 83 +++++++
.../feature/node/component/DeviceActions.kt | 153 ++++++++++---
.../node/component/DeviceDetailsSection.kt | 121 ++++++----
.../feature/node/component/MetricsSection.kt | 86 +++++--
.../node/component/NodeDetailsSection.kt | 216 ++++++++++--------
.../feature/node/component/NodeMenu.kt | 3 +
.../feature/node/component/NotesSection.kt | 78 ++++---
.../feature/node/component/PositionSection.kt | 187 ++++++++++-----
.../node/component/RemoteDeviceActions.kt | 140 +++++++++---
.../feature/node/detail/NodeDetailList.kt | 14 +-
.../node/detail/NodeDetailViewModel.kt | 12 +
16 files changed, 991 insertions(+), 360 deletions(-)
create mode 100644 core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index b81c83811..f533104b5 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -81,6 +81,7 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.model.util.anonymize
@@ -2926,5 +2927,35 @@ class MeshService : Service() {
},
)
}
+
+ override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) = toRemoteExceptions {
+ if (destNum != myNodeNum) {
+ val telemetryRequest = telemetry {
+ when (type) {
+ TelemetryType.ENVIRONMENT.ordinal ->
+ environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
+ TelemetryType.AIR_QUALITY.ordinal ->
+ airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance()
+ TelemetryType.POWER.ordinal ->
+ powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance()
+ TelemetryType.LOCAL_STATS.ordinal ->
+ localStats = TelemetryProtos.LocalStats.getDefaultInstance()
+ TelemetryType.DEVICE.ordinal ->
+ deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
+ else -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
+ }
+ }
+ packetHandler.sendToRadio(
+ newMeshPacketTo(destNum).buildMeshPacket(
+ id = requestId,
+ channel = nodeDBbyNodeNum[destNum]?.channel ?: 0,
+ ) {
+ portnumValue = Portnums.PortNum.TELEMETRY_APP_VALUE
+ payload = telemetryRequest.toByteString()
+ wantResponse = true
+ },
+ )
+ }
+ }
}
}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt
new file mode 100644
index 000000000..41d3136e1
--- /dev/null
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.meshtastic.core.model
+
+enum class TelemetryType {
+ DEVICE,
+ ENVIRONMENT,
+ AIR_QUALITY,
+ POWER,
+ LOCAL_STATS,
+}
diff --git a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
index 69116184c..0015176e8 100644
--- a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
+++ b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
@@ -179,4 +179,7 @@ interface IMeshService {
/// Request device connection status from the radio
void getDeviceConnectionStatus(in int requestId, in int destNum);
-}
\ No newline at end of file
+
+ /// Send request for telemetry to nodeNum
+ void requestTelemetry(in int requestId, in int destNum, in int type);
+}
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 640cc4779..b3bfd814d 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -300,6 +300,7 @@
Instantly send
Show quick chat menu
Hide quick chat menu
+ Show quick chat
Factory reset
Bluetooth is disabled. Please enable it in your device settings.
Open settings
@@ -351,6 +352,7 @@
Not muted
Muted for %1d days, %.1f hours
Muted for %.1f hours
+ Mute status
Replace
Scan WiFi QR code
Invalid WiFi Credential QR code format
@@ -742,8 +744,15 @@
Warning: This contact is known, importing will overwrite the previous contact information.
Public Key Changed
Import
- Request NeighborInfo (2.7.15+)
- Request Metadata
+ Request
+ NeighborInfo (2.7.15+)
+ Request Telemetry
+ Device Metrics
+ Environment Metrics
+ Air-Quality Metrics
+ Power Metrics
+ Local Stats
+ Metadata
Actions
Firmware
Use 12h clock format
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt
index b26eac004..0b961ff75 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt
@@ -17,15 +17,23 @@
package org.meshtastic.feature.node.component
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ForkLeft
import androidx.compose.material.icons.filled.Icecream
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.asDeviceVersion
@@ -44,7 +52,6 @@ import org.meshtastic.core.strings.remote_admin
import org.meshtastic.core.strings.request_metadata
import org.meshtastic.core.ui.component.InsetDivider
import org.meshtastic.core.ui.component.ListItem
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@@ -53,7 +60,6 @@ import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.MeshProtos
-@Suppress("LongMethod")
@Composable
fun AdministrationSection(
node: Node,
@@ -62,31 +68,77 @@ fun AdministrationSection(
onFirmwareSelect: (FirmwareRelease) -> Unit,
modifier: Modifier = Modifier,
) {
- TitledCard(stringResource(Res.string.administration), modifier = modifier) {
- ListItem(
- text = stringResource(Res.string.request_metadata),
- leadingIcon = Icons.Default.Memory,
- trailingIcon = null,
- onClick = { onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num))) },
- )
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(vertical = 16.dp)) {
+ AdministrationHeader()
- InsetDivider()
+ ListItem(
+ text = stringResource(Res.string.request_metadata),
+ leadingIcon = Icons.Default.Memory,
+ trailingIcon = null,
+ onClick = {
+ onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num)))
+ },
+ )
- ListItem(
- text = stringResource(Res.string.remote_admin),
- leadingIcon = Icons.Default.Settings,
- enabled = metricsState.isLocal || node.metadata != null,
- ) {
- onAction(NodeDetailAction.Navigate(SettingsRoutes.Settings(node.num)))
+ InsetDivider()
+
+ ListItem(
+ text = stringResource(Res.string.remote_admin),
+ leadingIcon = Icons.Default.Settings,
+ enabled = metricsState.isLocal || node.metadata != null,
+ ) {
+ onAction(NodeDetailAction.Navigate(SettingsRoutes.Settings(node.num)))
+ }
}
}
+
val firmwareVersion = node.metadata?.firmwareVersion
val firmwareEdition = metricsState.firmwareEdition
if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) {
- TitledCard(stringResource(Res.string.firmware)) {
- firmwareEdition?.let {
+ FirmwareSection(metricsState, firmwareEdition, firmwareVersion, onFirmwareSelect)
+ }
+}
+
+@Composable
+private fun AdministrationHeader() {
+ Text(
+ text = stringResource(Res.string.administration),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+}
+
+@Composable
+private fun FirmwareSection(
+ metricsState: MetricsState,
+ firmwareEdition: MeshProtos.FirmwareEdition?,
+ firmwareVersion: String?,
+ onFirmwareSelect: (FirmwareRelease) -> Unit,
+) {
+ ElevatedCard(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(vertical = 16.dp)) {
+ Text(
+ text = stringResource(Res.string.firmware),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+
+ firmwareEdition?.let { edition ->
val icon =
- when (it) {
+ when (edition) {
MeshProtos.FirmwareEdition.VANILLA -> Icons.Default.Icecream
else -> Icons.Default.ForkLeft
}
@@ -94,57 +146,68 @@ fun AdministrationSection(
ListItem(
text = stringResource(Res.string.firmware_edition),
leadingIcon = icon,
- supportingText = it.name,
+ supportingText = edition.name,
copyable = true,
trailingIcon = null,
)
}
- firmwareVersion?.let { firmwareVersion ->
- val latestStable = metricsState.latestStableFirmware
- val latestAlpha = metricsState.latestAlphaFirmware
- val deviceVersion = DeviceVersion(firmwareVersion.substringBeforeLast("."))
- val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha)
-
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.installed_firmware_version),
- leadingIcon = Icons.Default.Memory,
- supportingText = firmwareVersion.substringBeforeLast("."),
- copyable = true,
- leadingIconTint = statusColor,
- trailingIcon = null,
- )
-
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.latest_stable_firmware),
- leadingIcon = Icons.Default.Memory,
- supportingText = latestStable.id.substringBeforeLast(".").replace("v", ""),
- copyable = true,
- leadingIconTint = MaterialTheme.colorScheme.StatusGreen,
- trailingIcon = null,
- onClick = { onFirmwareSelect(latestStable) },
- )
-
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.latest_alpha_firmware),
- leadingIcon = Icons.Default.Memory,
- supportingText = latestAlpha.id.substringBeforeLast(".").replace("v", ""),
- copyable = true,
- leadingIconTint = MaterialTheme.colorScheme.StatusYellow,
- trailingIcon = null,
- onClick = { onFirmwareSelect(latestAlpha) },
- )
+ firmwareVersion?.let { version ->
+ FirmwareVersionItems(metricsState, version, firmwareEdition != null, onFirmwareSelect)
}
}
}
}
+@Composable
+private fun FirmwareVersionItems(
+ metricsState: MetricsState,
+ version: String,
+ hasEdition: Boolean,
+ onFirmwareSelect: (FirmwareRelease) -> Unit,
+) {
+ val latestStable = metricsState.latestStableFirmware
+ val latestAlpha = metricsState.latestAlphaFirmware
+
+ val deviceVersion = DeviceVersion(version.substringBeforeLast("."))
+ val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha)
+
+ if (hasEdition) InsetDivider()
+
+ ListItem(
+ text = stringResource(Res.string.installed_firmware_version),
+ leadingIcon = Icons.Default.Memory,
+ supportingText = version.substringBeforeLast("."),
+ copyable = true,
+ leadingIconTint = statusColor,
+ trailingIcon = null,
+ )
+
+ InsetDivider()
+
+ ListItem(
+ text = stringResource(Res.string.latest_stable_firmware),
+ leadingIcon = Icons.Default.Memory,
+ supportingText = latestStable.id.substringBeforeLast(".").replace("v", ""),
+ copyable = true,
+ leadingIconTint = MaterialTheme.colorScheme.StatusGreen,
+ trailingIcon = null,
+ onClick = { onFirmwareSelect(latestStable) },
+ )
+
+ InsetDivider()
+
+ ListItem(
+ text = stringResource(Res.string.latest_alpha_firmware),
+ leadingIcon = Icons.Default.Memory,
+ supportingText = latestAlpha.id.substringBeforeLast(".").replace("v", ""),
+ copyable = true,
+ leadingIconTint = MaterialTheme.colorScheme.StatusYellow,
+ trailingIcon = null,
+ onClick = { onFirmwareSelect(latestAlpha) },
+ )
+}
+
@Composable
private fun DeviceVersion.determineFirmwareStatusColor(
latestStable: FirmwareRelease,
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt
index 5fec486b9..ebac19807 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt
@@ -23,8 +23,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Route
import androidx.compose.material.icons.twotone.Mediation
+import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -68,6 +71,30 @@ fun TracerouteButton(
CooldownButton(text = text, leadingIcon = Icons.Default.Route, progress = progress.value, onClick = onClick)
}
+@Composable
+fun TracerouteChip(lastTracerouteTime: Long?, onClick: () -> Unit) {
+ val progress = remember { Animatable(0f) }
+
+ LaunchedEffect(lastTracerouteTime) {
+ val timeSinceLast = System.currentTimeMillis() - (lastTracerouteTime ?: 0)
+ if (timeSinceLast < COOL_DOWN_TIME_MS) {
+ val remainingTime = COOL_DOWN_TIME_MS - timeSinceLast
+ progress.snapTo(remainingTime / COOL_DOWN_TIME_MS.toFloat())
+ progress.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }),
+ )
+ }
+ }
+
+ CooldownChip(
+ text = stringResource(Res.string.traceroute),
+ leadingIcon = Icons.Default.Route,
+ progress = progress.value,
+ onClick = onClick,
+ )
+}
+
@Composable
fun RequestNeighborsButton(
text: String = stringResource(Res.string.request_neighbor_info),
@@ -91,6 +118,30 @@ fun RequestNeighborsButton(
CooldownButton(text = text, leadingIcon = Icons.TwoTone.Mediation, progress = progress.value, onClick = onClick)
}
+@Composable
+fun RequestNeighborsChip(lastRequestNeighborsTime: Long?, onClick: () -> Unit) {
+ val progress = remember { Animatable(0f) }
+
+ LaunchedEffect(lastRequestNeighborsTime) {
+ val timeSinceLast = System.currentTimeMillis() - (lastRequestNeighborsTime ?: 0)
+ if (timeSinceLast < REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS) {
+ val remainingTime = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS - timeSinceLast
+ progress.snapTo(remainingTime / REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS.toFloat())
+ progress.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }),
+ )
+ }
+ }
+
+ CooldownChip(
+ text = stringResource(Res.string.request_neighbor_info),
+ leadingIcon = Icons.TwoTone.Mediation,
+ progress = progress.value,
+ onClick = onClick,
+ )
+}
+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun CooldownButton(text: String, leadingIcon: ImageVector, progress: Float, onClick: () -> Unit) {
@@ -120,8 +171,40 @@ private fun CooldownButton(text: String, leadingIcon: ImageVector, progress: Flo
)
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun CooldownChip(text: String, leadingIcon: ImageVector, progress: Float, onClick: () -> Unit) {
+ val isCoolingDown = progress > 0f
+ val stroke = Stroke(width = with(LocalDensity.current) { 1.dp.toPx() }, cap = StrokeCap.Round)
+
+ AssistChip(
+ onClick = { if (!isCoolingDown) onClick() },
+ label = { Text(text) },
+ enabled = !isCoolingDown,
+ leadingIcon = {
+ if (isCoolingDown) {
+ CircularWavyProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.size(18.dp),
+ stroke = stroke,
+ trackStroke = stroke,
+ wavelength = 6.dp,
+ )
+ } else {
+ Icon(leadingIcon, contentDescription = null, modifier = Modifier.size(18.dp))
+ }
+ },
+ )
+}
+
@Preview(showBackground = true)
@Composable
private fun TracerouteButtonPreview() {
AppTheme { CooldownButton(text = "Traceroute", leadingIcon = Icons.Default.Route, progress = .6f, onClick = {}) }
}
+
+@Preview(showBackground = true)
+@Composable
+private fun TracerouteChipPreview() {
+ AppTheme { CooldownChip(text = "Traceroute", leadingIcon = Icons.Default.Route, progress = .6f, onClick = {}) }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
index 6293f3bb0..5cdf1062f 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
@@ -17,34 +17,55 @@
package org.meshtastic.feature.node.component
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.automirrored.outlined.VolumeMute
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.QrCode2
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.actions
+import org.meshtastic.core.strings.direct_message
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.share_contact
-import org.meshtastic.core.ui.component.InsetDivider
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchListItem
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.NodeDetailAction
+import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@Composable
fun DeviceActions(
@@ -73,50 +94,124 @@ fun DeviceActions(
onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
)
- TitledCard(title = stringResource(Res.string.actions), modifier = modifier) {
- ListItem(
- text = stringResource(Res.string.share_contact),
- leadingIcon = Icons.Rounded.QrCode2,
- trailingIcon = null,
- onClick = { onAction(NodeDetailAction.ShareContact) },
- )
- if (!isLocal) {
- InsetDivider()
- RemoteDeviceActions(
+
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(vertical = 12.dp)) {
+ Text(
+ text = stringResource(Res.string.actions),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ )
+
+ PrimaryActionsRow(node, isLocal, onAction, onFavoriteClick = { displayFavoriteDialog = true })
+
+ if (!isLocal) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+
+ RemoteDeviceActions(
+ node = node,
+ lastTracerouteTime = lastTracerouteTime,
+ lastRequestNeighborsTime = lastRequestNeighborsTime,
+ onAction = onAction,
+ )
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+
+ ManagementActions(
node = node,
- lastTracerouteTime = lastTracerouteTime,
- lastRequestNeighborsTime = lastRequestNeighborsTime,
- onAction = onAction,
+ onIgnoreClick = { displayIgnoreDialog = true },
+ onRemoveClick = { displayRemoveDialog = true },
)
}
+ }
+}
- InsetDivider()
+@Composable
+private fun PrimaryActionsRow(
+ node: Node,
+ isLocal: Boolean,
+ onAction: (NodeDetailAction) -> Unit,
+ onFavoriteClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (!node.isEffectivelyUnmessageable && !isLocal) {
+ Button(
+ onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) },
+ modifier = Modifier.weight(1f),
+ shape = MaterialTheme.shapes.large,
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Message, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(Res.string.direct_message))
+ }
+ }
- SwitchListItem(
- text = stringResource(Res.string.favorite),
- leadingIcon = if (node.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
- leadingIconTint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
- checked = node.isFavorite,
- onClick = { displayFavoriteDialog = true },
- )
+ OutlinedButton(
+ onClick = { onAction(NodeDetailAction.ShareContact) },
+ modifier = if (node.isEffectivelyUnmessageable || isLocal) Modifier.weight(1f) else Modifier,
+ shape = MaterialTheme.shapes.large,
+ ) {
+ Icon(Icons.Rounded.QrCode2, contentDescription = null)
+ if (node.isEffectivelyUnmessageable || isLocal) {
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(Res.string.share_contact))
+ }
+ }
- InsetDivider()
+ IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
+ Icon(
+ imageVector = if (node.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
+ contentDescription = stringResource(Res.string.favorite),
+ tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
+ )
+ }
+ }
+}
+@Composable
+private fun ManagementActions(node: Node, onIgnoreClick: () -> Unit, onRemoveClick: () -> Unit) {
+ Column {
SwitchListItem(
text = stringResource(Res.string.ignore),
leadingIcon =
- if (node.isIgnored) Icons.AutoMirrored.Outlined.VolumeMute else Icons.AutoMirrored.Default.VolumeUp,
+ if (node.isIgnored) {
+ Icons.AutoMirrored.Outlined.VolumeMute
+ } else {
+ Icons.AutoMirrored.Default.VolumeUp
+ },
checked = node.isIgnored,
- onClick = { displayIgnoreDialog = true },
+ onClick = onIgnoreClick,
)
- InsetDivider()
-
ListItem(
text = stringResource(Res.string.remove),
leadingIcon = Icons.Rounded.Delete,
trailingIcon = null,
- onClick = { displayRemoveDialog = true },
+ textColor = MaterialTheme.colorScheme.error,
+ leadingIconTint = MaterialTheme.colorScheme.error,
+ onClick = onRemoveClick,
)
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt
index 575427c83..786530be2 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt
@@ -19,8 +19,10 @@ package org.meshtastic.feature.node.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -28,7 +30,11 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.twotone.Verified
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -39,12 +45,12 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DeviceHardware
-import org.meshtastic.core.strings.R
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device
import org.meshtastic.core.strings.hardware
@@ -52,7 +58,6 @@ import org.meshtastic.core.strings.supported
import org.meshtastic.core.strings.supported_by_community
import org.meshtastic.core.ui.component.InsetDivider
import org.meshtastic.core.ui.component.ListItem
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.node.model.MetricsState
@@ -61,55 +66,79 @@ import org.meshtastic.feature.node.model.MetricsState
fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
val node = state.node ?: return
val deviceHardware = state.deviceHardware ?: return
- val hwModelName = deviceHardware.displayName
- val isSupported = deviceHardware.activelySupported
- TitledCard(stringResource(Res.string.device), modifier = modifier) {
- Spacer(modifier = Modifier.height(16.dp))
- Box(
- modifier =
- Modifier.align(Alignment.CenterHorizontally)
- .size(100.dp)
- .clip(CircleShape)
- .background(color = Color(node.colors.second).copy(alpha = .5f), shape = CircleShape),
- contentAlignment = Alignment.Center,
- ) {
- DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(vertical = 16.dp)) {
+ Text(
+ text = stringResource(Res.string.device),
+ style = MaterialTheme.typography.titleMedium,
+ color = colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ DeviceAvatar(node.colors.second.toLong(), deviceHardware)
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ InsetDivider()
+
+ ListItem(
+ text = stringResource(Res.string.hardware),
+ leadingIcon = Icons.Default.Router,
+ supportingText = deviceHardware.displayName,
+ copyable = true,
+ trailingIcon = null,
+ )
+
+ InsetDivider()
+
+ SupportStatusItem(deviceHardware.activelySupported)
}
-
- Spacer(modifier = Modifier.height(16.dp))
-
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.hardware),
- leadingIcon = Icons.Default.Router,
- supportingText = hwModelName,
- copyable = true,
- trailingIcon = null,
- )
-
- InsetDivider()
-
- ListItem(
- text =
- if (isSupported) {
- stringResource(Res.string.supported)
- } else {
- stringResource(Res.string.supported_by_community)
- },
- leadingIcon =
- if (isSupported) {
- Icons.TwoTone.Verified
- } else {
- ImageVector.vectorResource(org.meshtastic.feature.node.R.drawable.unverified)
- },
- leadingIconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
- trailingIcon = null,
- )
}
}
+@Composable
+private fun DeviceAvatar(bgColor: Long, deviceHardware: DeviceHardware) {
+ Box(
+ modifier =
+ Modifier.size(100.dp)
+ .clip(CircleShape)
+ .background(color = Color(bgColor).copy(alpha = .5f), shape = CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
+ }
+}
+
+@Composable
+private fun SupportStatusItem(isSupported: Boolean) {
+ ListItem(
+ text =
+ if (isSupported) {
+ stringResource(Res.string.supported)
+ } else {
+ stringResource(Res.string.supported_by_community)
+ },
+ leadingIcon =
+ if (isSupported) {
+ Icons.TwoTone.Verified
+ } else {
+ ImageVector.vectorResource(org.meshtastic.feature.node.R.drawable.unverified)
+ },
+ leadingIconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
+ trailingIcon = null,
+ )
+}
+
@Composable
private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifier = Modifier) {
val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg"
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt
index 43a828840..f83d8621d 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt
@@ -17,10 +17,19 @@
package org.meshtastic.feature.node.component
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
@@ -29,42 +38,93 @@ import org.meshtastic.core.strings.environment
import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.power
import org.meshtastic.core.ui.component.ListItem
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
-@Suppress("MultipleEmitters")
fun MetricsSection(
node: Node,
metricsState: MetricsState,
availableLogs: Set,
onAction: (NodeDetailAction) -> Unit,
- modifier: Modifier = Modifier,
) {
if (node.hasEnvironmentMetrics) {
- TitledCard(stringResource(Res.string.environment), modifier = modifier) {}
- EnvironmentMetrics(node, isFahrenheit = metricsState.isFahrenheit, displayUnits = metricsState.displayUnits)
- Spacer(modifier = Modifier.height(8.dp))
+ EnvironmentCard(node, metricsState)
}
if (node.hasPowerMetrics) {
- TitledCard(stringResource(Res.string.power), modifier = modifier) {}
- PowerMetrics(node)
- Spacer(modifier = Modifier.height(8.dp))
+ PowerCard(node)
}
val nonPositionLogs = availableLogs.filter { it != LogsType.NODE_MAP && it != LogsType.POSITIONS }
-
if (nonPositionLogs.isNotEmpty()) {
- TitledCard(title = stringResource(Res.string.logs), modifier = modifier) {
- nonPositionLogs.forEach { type ->
+ LogsCard(node, nonPositionLogs, onAction)
+ }
+}
+
+@Composable
+private fun EnvironmentCard(node: Node, metricsState: MetricsState) {
+ ElevatedCard(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ SectionTitle(stringResource(Res.string.environment))
+ Spacer(modifier = Modifier.height(12.dp))
+ EnvironmentMetrics(node, metricsState.displayUnits, metricsState.isFahrenheit)
+ }
+ }
+}
+
+@Composable
+private fun PowerCard(node: Node) {
+ ElevatedCard(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ SectionTitle(stringResource(Res.string.power))
+ Spacer(modifier = Modifier.height(12.dp))
+ PowerMetrics(node)
+ }
+ }
+}
+
+@Composable
+private fun LogsCard(node: Node, logs: List, onAction: (NodeDetailAction) -> Unit) {
+ ElevatedCard(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(vertical = 16.dp)) {
+ SectionTitle(stringResource(Res.string.logs), Modifier.padding(horizontal = 16.dp))
+ Spacer(modifier = Modifier.height(8.dp))
+ logs.forEachIndexed { index, type ->
+ if (index > 0) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+ }
ListItem(text = stringResource(type.titleRes), leadingIcon = type.icon) {
onAction(NodeDetailAction.Navigate(type.routeFactory(node.num)))
}
}
}
- Spacer(modifier = Modifier.height(8.dp))
}
}
+
+@Composable
+private fun SectionTitle(title: String, modifier: Modifier = Modifier) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier = modifier,
+ )
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
index ec98cac15..1b1fe312f 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
@@ -17,9 +17,14 @@
package org.meshtastic.feature.node.component
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
@@ -28,16 +33,18 @@ import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.Numbers
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Work
-import androidx.compose.material.icons.outlined.NoCell
-import androidx.compose.material.icons.outlined.Person
-import androidx.compose.material.icons.twotone.Person
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
@@ -48,123 +55,154 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.details
import org.meshtastic.core.strings.encryption_error
import org.meshtastic.core.strings.encryption_error_text
-import org.meshtastic.core.strings.long_name
import org.meshtastic.core.strings.node_number
import org.meshtastic.core.strings.node_sort_last_heard
import org.meshtastic.core.strings.role
import org.meshtastic.core.strings.short_name
-import org.meshtastic.core.strings.unmonitored_or_infrastructure
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.user_id
-import org.meshtastic.core.ui.component.InsetDivider
-import org.meshtastic.core.ui.component.ListItem
-import org.meshtastic.core.ui.component.TitledCard
-import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@Composable
fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) {
- TitledCard(title = stringResource(Res.string.details), modifier = modifier) {
- if (node.mismatchKey) {
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Text(
+ text = stringResource(Res.string.details),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Spacer(Modifier.height(20.dp))
+
+ if (node.mismatchKey) {
+ MismatchKeyWarning()
+ Spacer(Modifier.height(20.dp))
+ }
+
+ MainNodeDetails(node)
+ }
+ }
+}
+
+@Composable
+private fun MismatchKeyWarning() {
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ shape = MaterialTheme.shapes.large,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.KeyOff,
contentDescription = stringResource(Res.string.encryption_error),
- tint = Color.Red,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(Res.string.encryption_error),
- style = MaterialTheme.typography.titleLarge.copy(color = Color.Red),
- textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onErrorContainer,
)
}
- Spacer(Modifier.height(16.dp))
+ Spacer(Modifier.height(4.dp))
Text(
text = stringResource(Res.string.encryption_error_text),
- style = MaterialTheme.typography.bodyMedium,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
)
- Spacer(Modifier.height(16.dp))
}
- MainNodeDetails(node)
}
}
@Composable
private fun MainNodeDetails(node: Node) {
- ListItem(
- text = stringResource(Res.string.long_name),
- leadingIcon = Icons.TwoTone.Person,
- supportingText = node.user.longName.ifEmpty { "???" },
- copyable = true,
- trailingIcon = null,
- )
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ InfoItem(
+ label = stringResource(Res.string.short_name),
+ value = node.user.shortName.ifEmpty { "???" },
+ icon = Icons.Default.Person,
+ modifier = Modifier.weight(1f),
+ )
+ InfoItem(
+ label = stringResource(Res.string.role),
+ value = node.user.role.name,
+ icon = Icons.Default.Work,
+ modifier = Modifier.weight(1f),
+ )
+ }
- InsetDivider()
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
- ListItem(
- text = stringResource(Res.string.short_name),
- leadingIcon = Icons.Outlined.Person,
- supportingText = node.user.shortName.ifEmpty { "???" },
- copyable = true,
- trailingIcon = null,
- )
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ InfoItem(
+ label = stringResource(Res.string.node_sort_last_heard),
+ value = formatAgo(node.lastHeard),
+ icon = Icons.Default.History,
+ modifier = Modifier.weight(1f),
+ )
+ InfoItem(
+ label = stringResource(Res.string.node_number),
+ value = node.num.toUInt().toString(),
+ icon = Icons.Default.Numbers,
+ modifier = Modifier.weight(1f),
+ )
+ }
- InsetDivider()
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
- ListItem(
- text = stringResource(Res.string.node_number),
- leadingIcon = Icons.Default.Numbers,
- supportingText = node.num.toUInt().toString(),
- copyable = true,
- trailingIcon = null,
- )
-
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.user_id),
- leadingIcon = Icons.Default.Person,
- supportingText = node.user.id,
- copyable = true,
- trailingIcon = null,
- )
-
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.role),
- leadingIcon = Icons.Default.Work,
- supportingText = node.user.role.name,
- trailingIcon = null,
- )
-
- if (node.isEffectivelyUnmessageable) {
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.unmonitored_or_infrastructure),
- leadingIcon = Icons.Outlined.NoCell,
- trailingIcon = null,
- )
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ InfoItem(
+ label = stringResource(Res.string.user_id),
+ value = node.user.id,
+ icon = Icons.Default.Person,
+ modifier = Modifier.weight(1f),
+ )
+ if (node.deviceMetrics.uptimeSeconds > 0) {
+ InfoItem(
+ label = stringResource(Res.string.uptime),
+ value = formatUptime(node.deviceMetrics.uptimeSeconds),
+ icon = Icons.Default.CheckCircle,
+ modifier = Modifier.weight(1f),
+ )
+ } else {
+ Spacer(Modifier.weight(1f))
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoItem(label: String, value: String, icon: ImageVector, modifier: Modifier = Modifier) {
+ Column(modifier = modifier) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
+ )
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
}
- if (node.deviceMetrics.uptimeSeconds > 0) {
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.uptime),
- leadingIcon = Icons.Default.CheckCircle,
- supportingText = formatUptime(node.deviceMetrics.uptimeSeconds),
- trailingIcon = null,
- )
- }
-
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.node_sort_last_heard),
- leadingIcon = Icons.Default.History,
- supportingText = formatAgo(node.lastHeard),
- trailingIcon = null,
- )
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt
index ebdae2f34..2d3532de1 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt
@@ -20,6 +20,7 @@ package org.meshtastic.feature.node.component
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.favorite_add
@@ -100,6 +101,8 @@ sealed class NodeMenuAction {
data class RequestPosition(val node: Node) : NodeMenuAction()
+ data class RequestTelemetry(val node: Node, val type: TelemetryType) : NodeMenuAction()
+
data class TraceRoute(val node: Node) : NodeMenuAction()
data class MoreDetails(val node: Node) : NodeMenuAction()
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt
index 0c991ad83..48cbae124 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt
@@ -17,14 +17,20 @@
package org.meshtastic.feature.node.component
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -34,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
@@ -42,42 +49,57 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_a_note
import org.meshtastic.core.strings.notes
import org.meshtastic.core.strings.save
-import org.meshtastic.core.ui.component.TitledCard
@Composable
fun NotesSection(node: Node, onSaveNotes: (Int, String) -> Unit, modifier: Modifier = Modifier) {
if (node.isFavorite) {
- TitledCard(title = stringResource(Res.string.notes), modifier = modifier) {
- val originalNotes = node.notes
- var notes by remember(node.notes) { mutableStateOf(node.notes) }
- val edited = notes.trim() != originalNotes.trim()
- val keyboardController = LocalSoftwareKeyboardController.current
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Text(
+ text = stringResource(Res.string.notes),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ )
- OutlinedTextField(
- value = notes,
- onValueChange = { notes = it },
- modifier = Modifier.fillMaxWidth().padding(8.dp),
- placeholder = { Text(stringResource(Res.string.add_a_note)) },
- trailingIcon = {
- IconButton(
- onClick = {
+ Spacer(Modifier.height(16.dp))
+
+ val originalNotes = node.notes
+ var notes by remember(node.notes) { mutableStateOf(node.notes) }
+ val edited = notes.trim() != originalNotes.trim()
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ OutlinedTextField(
+ value = notes,
+ onValueChange = { notes = it },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text(stringResource(Res.string.add_a_note)) },
+ shape = MaterialTheme.shapes.large,
+ trailingIcon = {
+ IconButton(
+ onClick = {
+ onSaveNotes(node.num, notes.trim())
+ keyboardController?.hide()
+ },
+ enabled = edited,
+ ) {
+ Icon(imageVector = Icons.Default.Save, contentDescription = stringResource(Res.string.save))
+ }
+ },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions =
+ KeyboardActions(
+ onDone = {
onSaveNotes(node.num, notes.trim())
keyboardController?.hide()
},
- enabled = edited,
- ) {
- Icon(imageVector = Icons.Default.Save, contentDescription = stringResource(Res.string.save))
- }
- },
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
- keyboardActions =
- KeyboardActions(
- onDone = {
- onSaveNotes(node.num, notes.trim())
- keyboardController?.hide()
- },
- ),
- )
+ ),
+ )
+ }
}
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index 4ff9fadd8..7ed357da5 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -17,29 +17,49 @@
package org.meshtastic.feature.node.component
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.SocialDistance
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.exchange_position
-import org.meshtastic.core.strings.node_sort_distance
import org.meshtastic.core.strings.open_compass
import org.meshtastic.core.strings.position
-import org.meshtastic.core.ui.component.InsetDivider
-import org.meshtastic.core.ui.component.ListItem
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
+import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
/**
* Displays node position details, last update time, distance, and related actions like requesting position and
@@ -56,63 +76,120 @@ fun PositionSection(
) {
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits)
val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0
- TitledCard(title = stringResource(Res.string.position), modifier = modifier) {
- // Current position coordinates (linked)
- if (hasValidPosition) {
- InlineMap(node = node, Modifier.fillMaxWidth().height(200.dp))
-
- LinkedCoordinatesItem(node)
- }
-
- // Distance (if available)
- if (distance != null && distance.isNotEmpty()) {
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.node_sort_distance),
- leadingIcon = Icons.Default.SocialDistance,
- supportingText = distance,
- copyable = true,
- trailingIcon = null,
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = stringResource(Res.string.position),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
)
- }
- InsetDivider()
+ Spacer(Modifier.height(16.dp))
- // Exchange position action
- ListItem(
- text = stringResource(Res.string.exchange_position),
- leadingIcon = Icons.Default.LocationOn,
- trailingIcon = null,
- onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
- )
-
- if (hasValidPosition) {
- InsetDivider()
-
- ListItem(
- text = stringResource(Res.string.open_compass),
- leadingIcon = Icons.Default.Explore,
- trailingIcon = null,
- onClick = { onAction(NodeDetailAction.OpenCompass(node, metricsState.displayUnits)) },
- )
- }
-
- // Node Map log
- if (availableLogs.contains(LogsType.NODE_MAP)) {
- InsetDivider()
-
- ListItem(text = stringResource(LogsType.NODE_MAP.titleRes), leadingIcon = LogsType.NODE_MAP.icon) {
- onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num)))
+ if (hasValidPosition) {
+ PositionMap(node, distance)
+ LinkedCoordinatesItem(node)
+ Spacer(Modifier.height(8.dp))
}
- }
- // Positions Log
- if (availableLogs.contains(LogsType.POSITIONS)) {
- InsetDivider()
+ PositionActionButtons(node, hasValidPosition, metricsState.displayUnits, onAction)
- ListItem(text = stringResource(LogsType.POSITIONS.titleRes), leadingIcon = LogsType.POSITIONS.icon) {
- onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num)))
+ if (availableLogs.contains(LogsType.NODE_MAP) || availableLogs.contains(LogsType.POSITIONS)) {
+ Spacer(Modifier.height(12.dp))
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (availableLogs.contains(LogsType.NODE_MAP)) {
+ AssistChip(
+ onClick = { onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num))) },
+ label = { Text(stringResource(LogsType.NODE_MAP.titleRes)) },
+ leadingIcon = { Icon(LogsType.NODE_MAP.icon, null, Modifier.size(18.dp)) },
+ )
+ }
+
+ if (availableLogs.contains(LogsType.POSITIONS)) {
+ AssistChip(
+ onClick = {
+ onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num)))
+ },
+ label = { Text(stringResource(LogsType.POSITIONS.titleRes)) },
+ leadingIcon = { Icon(LogsType.POSITIONS.icon, null, Modifier.size(18.dp)) },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PositionMap(node: Node, distance: String?) {
+ Box(modifier = Modifier.padding(vertical = 4.dp)) {
+ Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(200.dp)) {
+ InlineMap(node = node, Modifier.fillMaxSize())
+ }
+ if (distance != null && distance.isNotEmpty()) {
+ Surface(
+ modifier = Modifier.padding(12.dp).align(Alignment.TopEnd),
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(Icons.Default.SocialDistance, null, Modifier.size(16.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(distance, style = MaterialTheme.typography.labelLarge)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PositionActionButtons(
+ node: Node,
+ hasValidPosition: Boolean,
+ displayUnits: DisplayUnits,
+ onAction: (NodeDetailAction) -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Button(
+ onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
+ modifier = Modifier.weight(1f),
+ shape = MaterialTheme.shapes.large,
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ ) {
+ Icon(Icons.Default.LocationOn, null, Modifier.size(20.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(text = stringResource(Res.string.exchange_position), maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+
+ if (hasValidPosition) {
+ FilledTonalButton(
+ onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
+ modifier = Modifier.weight(1f),
+ shape = MaterialTheme.shapes.large,
+ ) {
+ Icon(Icons.Default.Explore, null, Modifier.size(20.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(text = stringResource(Res.string.open_compass), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt
index f43240a60..12e8e6f22 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt
@@ -17,53 +17,133 @@
package org.meshtastic.feature.node.component
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.twotone.Message
+import androidx.compose.material.icons.filled.AreaChart
import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.direct_message
import org.meshtastic.core.strings.exchange_userinfo
-import org.meshtastic.core.ui.component.InsetDivider
-import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.strings.request
+import org.meshtastic.core.strings.request_air_quality_metrics
+import org.meshtastic.core.strings.request_device_metrics
+import org.meshtastic.core.strings.request_environment_metrics
+import org.meshtastic.core.strings.request_local_stats
+import org.meshtastic.core.strings.request_power_metrics
import org.meshtastic.feature.node.model.NodeDetailAction
-import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@Composable
+@Suppress("LongMethod")
internal fun RemoteDeviceActions(
node: Node,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
onAction: (NodeDetailAction) -> Unit,
) {
- if (!node.isEffectivelyUnmessageable) {
- ListItem(
- text = stringResource(Res.string.direct_message),
- leadingIcon = Icons.AutoMirrored.TwoTone.Message,
- trailingIcon = null,
- onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) },
+ Column(modifier = Modifier.padding(vertical = 4.dp)) {
+ Text(
+ text = stringResource(Res.string.request),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.secondary,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
)
- InsetDivider()
+ FlowRow(
+ modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ AssistChip(
+ onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestUserInfo(node))) },
+ label = { Text(stringResource(Res.string.exchange_userinfo)) },
+ leadingIcon = { Icon(Icons.Default.Person, contentDescription = null, Modifier.size(18.dp)) },
+ )
+
+ TracerouteChip(
+ lastTracerouteTime = lastTracerouteTime,
+ onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) },
+ )
+
+ RequestNeighborsChip(
+ lastRequestNeighborsTime = lastRequestNeighborsTime,
+ onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestNeighborInfo(node))) },
+ )
+
+ AssistChip(
+ onClick = {
+ onAction(
+ NodeDetailAction.HandleNodeMenuAction(
+ NodeMenuAction.RequestTelemetry(node, TelemetryType.DEVICE),
+ ),
+ )
+ },
+ label = { Text(stringResource(Res.string.request_device_metrics)) },
+ leadingIcon = { Icon(Icons.Default.AreaChart, contentDescription = null, Modifier.size(18.dp)) },
+ )
+
+ AssistChip(
+ onClick = {
+ onAction(
+ NodeDetailAction.HandleNodeMenuAction(
+ NodeMenuAction.RequestTelemetry(node, TelemetryType.ENVIRONMENT),
+ ),
+ )
+ },
+ label = { Text(stringResource(Res.string.request_environment_metrics)) },
+ leadingIcon = { Icon(Icons.Default.AreaChart, contentDescription = null, Modifier.size(18.dp)) },
+ )
+
+ AssistChip(
+ onClick = {
+ onAction(
+ NodeDetailAction.HandleNodeMenuAction(
+ NodeMenuAction.RequestTelemetry(node, TelemetryType.AIR_QUALITY),
+ ),
+ )
+ },
+ label = { Text(stringResource(Res.string.request_air_quality_metrics)) },
+ leadingIcon = { Icon(Icons.Default.AreaChart, contentDescription = null, Modifier.size(18.dp)) },
+ )
+
+ AssistChip(
+ onClick = {
+ onAction(
+ NodeDetailAction.HandleNodeMenuAction(
+ NodeMenuAction.RequestTelemetry(node, TelemetryType.POWER),
+ ),
+ )
+ },
+ label = { Text(stringResource(Res.string.request_power_metrics)) },
+ leadingIcon = { Icon(Icons.Default.AreaChart, contentDescription = null, Modifier.size(18.dp)) },
+ )
+
+ AssistChip(
+ onClick = {
+ onAction(
+ NodeDetailAction.HandleNodeMenuAction(
+ NodeMenuAction.RequestTelemetry(node, TelemetryType.LOCAL_STATS),
+ ),
+ )
+ },
+ label = { Text(stringResource(Res.string.request_local_stats)) },
+ leadingIcon = { Icon(Icons.Default.AreaChart, contentDescription = null, Modifier.size(18.dp)) },
+ )
+ }
}
-
- ListItem(
- text = stringResource(Res.string.exchange_userinfo),
- leadingIcon = Icons.Default.Person,
- trailingIcon = null,
- onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestUserInfo(node))) },
- )
-
- InsetDivider()
-
- TracerouteButton(
- lastTracerouteTime = lastTracerouteTime,
- onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) },
- )
- RequestNeighborsButton(
- lastRequestNeighborsTime = lastRequestNeighborsTime,
- onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestNeighborInfo(node))) },
- )
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt
index c88bc233c..04e8c896d 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt
@@ -155,16 +155,10 @@ fun NodeDetailList(
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp).focusable(),
- verticalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
) {
- if (metricsState.deviceHardware != null) {
- DeviceDetailsSection(metricsState)
- }
-
NodeDetailsSection(node)
- NotesSection(node = node, onSaveNotes = onSaveNotes)
-
DeviceActions(
isLocal = metricsState.isLocal,
lastTracerouteTime = lastTracerouteTime,
@@ -191,8 +185,14 @@ fun NodeDetailList(
},
)
+ if (metricsState.deviceHardware != null) {
+ DeviceDetailsSection(metricsState)
+ }
+
MetricsSection(node, metricsState, availableLogs, onAction)
+ NotesSection(node = node, onSaveNotes = onSaveNotes)
+
if (!metricsState.isManaged) {
AdministrationSection(
node = node,
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
index 80806ed1b..a9a28055b 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
@@ -29,6 +29,7 @@ import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.feature.node.component.NodeMenuAction
@@ -62,6 +63,7 @@ constructor(
_lastRequestNeighborsTime.value = System.currentTimeMillis()
}
is NodeMenuAction.RequestPosition -> requestPosition(action.node.num)
+ is NodeMenuAction.RequestTelemetry -> requestTelemetry(action.node.num, action.type)
is NodeMenuAction.TraceRoute -> {
requestTraceroute(action.node.num)
_lastTraceRouteTime.value = System.currentTimeMillis()
@@ -136,6 +138,16 @@ constructor(
}
}
+ private fun requestTelemetry(destNum: Int, type: TelemetryType) {
+ Timber.i("Requesting telemetry for '$destNum'")
+ try {
+ val packetId = serviceRepository.meshService?.packetId ?: return
+ serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal)
+ } catch (ex: RemoteException) {
+ Timber.e("Request telemetry error: ${ex.message}")
+ }
+ }
+
private fun requestTraceroute(destNum: Int) {
Timber.i("Requesting traceroute for '$destNum'")
try {