diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
index 14688a602..4e078e7f1 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
@@ -39,6 +39,7 @@ import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.TelemetryProtos
+import org.meshtastic.proto.paxcount
import org.meshtastic.proto.position
import org.meshtastic.proto.telemetry
import java.util.concurrent.ConcurrentHashMap
@@ -272,23 +273,39 @@ constructor(
fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
- val telemetryRequest = telemetry {
- when (type) {
- TelemetryType.ENVIRONMENT ->
- environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
- TelemetryType.AIR_QUALITY -> airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance()
- TelemetryType.POWER -> powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance()
- TelemetryType.LOCAL_STATS -> localStats = TelemetryProtos.LocalStats.getDefaultInstance()
- TelemetryType.DEVICE -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
- }
+
+ val portNum: Portnums.PortNum
+ val payloadBytes: ByteString
+
+ if (type == TelemetryType.PAX) {
+ portNum = Portnums.PortNum.PAXCOUNTER_APP
+ payloadBytes = paxcount {}.toByteString()
+ } else {
+ portNum = Portnums.PortNum.TELEMETRY_APP
+ payloadBytes =
+ telemetry {
+ when (type) {
+ TelemetryType.ENVIRONMENT ->
+ environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
+ TelemetryType.AIR_QUALITY ->
+ airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance()
+ TelemetryType.POWER -> powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance()
+ TelemetryType.LOCAL_STATS -> localStats = TelemetryProtos.LocalStats.getDefaultInstance()
+ TelemetryType.DEVICE -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
+ TelemetryType.HOST -> hostMetrics = TelemetryProtos.HostMetrics.getDefaultInstance()
+ else -> {}
+ }
+ }
+ .toByteString()
}
+
packetHandler?.sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(
id = requestId,
channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
) {
- portnumValue = Portnums.PortNum.TELEMETRY_APP_VALUE
- payload = telemetryRequest.toByteString()
+ portnumValue = portNum.number
+ payload = payloadBytes
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
index 41d3136e1..25088b7b6 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,7 +14,6 @@
* 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 {
@@ -23,4 +22,6 @@ enum class TelemetryType {
AIR_QUALITY,
POWER,
LOCAL_STATS,
+ HOST,
+ PAX,
}
diff --git a/core/strings/src/androidMain/kotlin/com/meshtastic/core/strings/ContextExt.kt b/core/strings/src/androidMain/kotlin/com/meshtastic/core/strings/ContextExt.kt
index 9361e7633..4c8cf75ec 100644
--- a/core/strings/src/androidMain/kotlin/com/meshtastic/core/strings/ContextExt.kt
+++ b/core/strings/src/androidMain/kotlin/com/meshtastic/core/strings/ContextExt.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,16 +14,25 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.meshtastic.core.strings
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.getString
fun getString(stringResource: StringResource): String = runBlocking {
org.jetbrains.compose.resources.getString(stringResource)
}
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
- org.jetbrains.compose.resources.getString(stringResource, *formatArgs)
+ val resolvedArgs =
+ formatArgs.map { arg ->
+ if (arg is StringResource) {
+ getString(arg)
+ } else {
+ arg
+ }
+ }
+ @Suppress("SpreadOperator")
+ org.jetbrains.compose.resources.getString(stringResource, *resolvedArgs.toTypedArray())
}
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 24cc35987..21a1d408d 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -411,7 +411,7 @@
Direct messages are using the new public key infrastructure for encryption.
Public key mismatch
The public key does not match the recorded key. You may remove the node and let it exchange keys again, but this may indicate a more serious security problem. Contact the user through another trusted channel, to determine if the key change was due to a factory reset or other intentional action.
- Exchange user info
+ User Info
New node notifications
More details
SNR
@@ -419,12 +419,12 @@
RSSI
Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection.
(Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500.
- Device Metrics Log
+ Device Metrics
Node Map
- Position Log
+ Position
Last position update
- Environment Metrics Log
- Signal Metrics Log
+ Environment Metrics
+ Signal Metrics
Administration
Remote Administration
Bad
@@ -434,7 +434,7 @@
Share to…
Signal
Signal Quality
- Traceroute Log
+ Traceroute
Direct
- 1 hop
@@ -466,7 +466,7 @@
Remove from favorites
Add '%1$s' as a favorite node?
Remove '%1$s' as a favorite node?
- Power Metrics Log
+ Power Metrics
Channel 1
Channel 2
Channel 3
@@ -787,6 +787,8 @@
Public Key Changed
Import
Request
+ Requesting %1$s from %2$s
+ User info
NeighborInfo (2.7.15+)
Request Telemetry
Device Metrics
@@ -794,12 +796,14 @@
Air-Quality Metrics
Power Metrics
Local Stats
+ Host Metrics
+ Pax Metrics
Metadata
Actions
Firmware
Use 12h clock format
When enabled, the device will display the time in 12-hour format on screen.
- Host Metrics Log
+ Host Metrics
Host
Free Memory
Disk Free
@@ -897,9 +901,9 @@
Clear selection
Message
Type a message
- PAX Metrics Log
+ PAX Metrics
PAX
- No PAX metrics logs available.
+ No PAX metrics available.
WiFi Devices
BLE Devices
Paired devices
@@ -1143,6 +1147,7 @@
The following channels were found in the QR code. Select the once you would like to add to your device. Existing channels will be preserved.
Replace Channels & Settings
This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed.
+ Loading
Message Filter
diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts
index a039d5a4d..5deaa65da 100644
--- a/feature/node/build.gradle.kts
+++ b/feature/node/build.gradle.kts
@@ -47,6 +47,8 @@ dependencies {
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.navigation.common)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.molecule.runtime)
implementation(libs.kermit)
implementation(libs.coil)
implementation(libs.markdown.renderer.android)
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 0b961ff75..b9f38fbf6 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
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,26 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
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
@@ -50,7 +42,6 @@ import org.meshtastic.core.strings.latest_alpha_firmware
import org.meshtastic.core.strings.latest_stable_firmware
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.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
@@ -68,14 +59,8 @@ fun AdministrationSection(
onFirmwareSelect: (FirmwareRelease) -> Unit,
modifier: Modifier = Modifier,
) {
- ElevatedCard(
- modifier = modifier.fillMaxWidth(),
- colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
- shape = MaterialTheme.shapes.extraLarge,
- ) {
- Column(modifier = Modifier.padding(vertical = 16.dp)) {
- AdministrationHeader()
-
+ SectionCard(title = Res.string.administration, modifier = modifier) {
+ Column {
ListItem(
text = stringResource(Res.string.request_metadata),
leadingIcon = Icons.Default.Memory,
@@ -85,7 +70,7 @@ fun AdministrationSection(
},
)
- InsetDivider()
+ SectionDivider()
ListItem(
text = stringResource(Res.string.remote_admin),
@@ -104,17 +89,6 @@ fun AdministrationSection(
}
}
-@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,
@@ -122,20 +96,8 @@ private fun FirmwareSection(
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),
- )
-
+ SectionCard(title = Res.string.firmware) {
+ Column {
firmwareEdition?.let { edition ->
val icon =
when (edition) {
@@ -172,7 +134,7 @@ private fun FirmwareVersionItems(
val deviceVersion = DeviceVersion(version.substringBeforeLast("."))
val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha)
- if (hasEdition) InsetDivider()
+ if (hasEdition) SectionDivider()
ListItem(
text = stringResource(Res.string.installed_firmware_version),
@@ -183,7 +145,7 @@ private fun FirmwareVersionItems(
trailingIcon = null,
)
- InsetDivider()
+ SectionDivider()
ListItem(
text = stringResource(Res.string.latest_stable_firmware),
@@ -195,7 +157,7 @@ private fun FirmwareVersionItems(
onClick = { onFirmwareSelect(latestStable) },
)
- InsetDivider()
+ SectionDivider()
ListItem(
text = stringResource(Res.string.latest_alpha_firmware),
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
deleted file mode 100644
index ebac19807..000000000
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt
+++ /dev/null
@@ -1,210 +0,0 @@
-/*
- * 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.feature.node.component
-
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.tween
-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
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.StrokeCap
-import androidx.compose.ui.graphics.drawscope.Stroke
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.request_neighbor_info
-import org.meshtastic.core.strings.traceroute
-import org.meshtastic.core.ui.component.BasicListItem
-import org.meshtastic.core.ui.theme.AppTheme
-
-private const val COOL_DOWN_TIME_MS = 30000L
-private const val REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS = 180000L // 3 minutes
-
-@Composable
-fun TracerouteButton(
- text: String = stringResource(Res.string.traceroute),
- 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 }),
- )
- }
- }
-
- 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),
- 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 }),
- )
- }
- }
-
- 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) {
- val isCoolingDown = progress > 0f
- val stroke = Stroke(width = with(LocalDensity.current) { 2.dp.toPx() }, cap = StrokeCap.Round)
-
- BasicListItem(
- text = text,
- enabled = !isCoolingDown,
- leadingIcon = leadingIcon,
- trailingContent = {
- if (isCoolingDown) {
- CircularWavyProgressIndicator(
- progress = { progress },
- modifier = Modifier.size(24.dp),
- stroke = stroke,
- trackStroke = stroke,
- wavelength = 8.dp,
- )
- }
- },
- onClick = {
- if (!isCoolingDown) {
- onClick()
- }
- },
- )
-}
-
-@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/CooldownOutlinedIconButton.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownOutlinedIconButton.kt
new file mode 100644
index 000000000..9d562299d
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownOutlinedIconButton.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2025-2026 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.feature.node.component
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.OutlinedIconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.meshtastic.core.ui.theme.AppTheme
+
+internal const val COOL_DOWN_TIME_MS = 30000L
+internal const val REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS = 180000L // 3 minutes
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun CooldownIconButton(
+ onClick: () -> Unit,
+ cooldownTimestamp: Long?,
+ cooldownDuration: Long = COOL_DOWN_TIME_MS,
+ content: @Composable () -> Unit,
+) {
+ val progress = remember { Animatable(0f) }
+
+ LaunchedEffect(cooldownTimestamp) {
+ if (cooldownTimestamp == null) {
+ progress.snapTo(0f)
+ return@LaunchedEffect
+ }
+ val timeSinceLast = System.currentTimeMillis() - cooldownTimestamp
+ if (timeSinceLast < cooldownDuration) {
+ val remainingTime = cooldownDuration - timeSinceLast
+ progress.snapTo(remainingTime / cooldownDuration.toFloat())
+ progress.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }),
+ )
+ } else {
+ progress.snapTo(0f)
+ }
+ }
+
+ val isCoolingDown = progress.value > 0f
+ val stroke = Stroke(width = with(LocalDensity.current) { 2.dp.toPx() }, cap = StrokeCap.Round)
+
+ IconButton(
+ onClick = { if (!isCoolingDown) onClick() },
+ enabled = !isCoolingDown,
+ colors = IconButtonDefaults.iconButtonColors(),
+ ) {
+ if (isCoolingDown) {
+ CircularWavyProgressIndicator(
+ progress = { progress.value },
+ modifier = Modifier.size(24.dp),
+ stroke = stroke,
+ trackStroke = stroke,
+ wavelength = 8.dp,
+ )
+ } else {
+ content()
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun CooldownOutlinedIconButton(
+ onClick: () -> Unit,
+ cooldownTimestamp: Long?,
+ cooldownDuration: Long = COOL_DOWN_TIME_MS,
+ content: @Composable () -> Unit,
+) {
+ val progress = remember { Animatable(0f) }
+
+ LaunchedEffect(cooldownTimestamp) {
+ if (cooldownTimestamp == null) {
+ progress.snapTo(0f)
+ return@LaunchedEffect
+ }
+ val timeSinceLast = System.currentTimeMillis() - cooldownTimestamp
+ if (timeSinceLast < cooldownDuration) {
+ val remainingTime = cooldownDuration - timeSinceLast
+ progress.snapTo(remainingTime / cooldownDuration.toFloat())
+ progress.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }),
+ )
+ } else {
+ progress.snapTo(0f)
+ }
+ }
+
+ val isCoolingDown = progress.value > 0f
+ val stroke = Stroke(width = with(LocalDensity.current) { 2.dp.toPx() }, cap = StrokeCap.Round)
+
+ OutlinedIconButton(
+ onClick = { if (!isCoolingDown) onClick() },
+ enabled = !isCoolingDown,
+ shapes = IconButtonDefaults.shapes(),
+ colors = IconButtonDefaults.outlinedIconButtonColors(),
+ ) {
+ if (isCoolingDown) {
+ CircularWavyProgressIndicator(
+ progress = { progress.value },
+ modifier = Modifier.size(24.dp),
+ stroke = stroke,
+ trackStroke = stroke,
+ wavelength = 8.dp,
+ )
+ } else {
+ content()
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CooldownOutlinedIconButtonPreview() {
+ AppTheme {
+ CooldownOutlinedIconButton(onClick = {}, cooldownTimestamp = System.currentTimeMillis() - 15000L) {
+ Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
+ }
+ }
+}
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 05d4eadcc..7db6bd588 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
@@ -34,9 +34,6 @@ 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
@@ -51,7 +48,6 @@ 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
@@ -65,6 +61,8 @@ import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchListItem
+import org.meshtastic.feature.node.model.LogsType
+import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@@ -80,7 +78,9 @@ fun DeviceActions(
node: Node,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
+ availableLogs: Set,
onAction: (NodeDetailAction) -> Unit,
+ metricsState: MetricsState,
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
@@ -99,34 +99,15 @@ fun DeviceActions(
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
)
- ElevatedCard(
- modifier = modifier.fillMaxWidth(),
- colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
- shape = MaterialTheme.shapes.extraLarge,
- ) {
- Column(modifier = Modifier.padding(vertical = 12.dp)) {
- ActionsHeader()
-
+ Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ SectionCard(title = Res.string.actions) {
PrimaryActionsRow(
node = node,
isLocal = isLocal,
onAction = onAction,
onFavoriteClick = { displayedDialog = DialogType.FAVORITE },
)
-
- if (!isLocal) {
- ActionsDivider()
-
- RemoteDeviceActions(
- node = node,
- lastTracerouteTime = lastTracerouteTime,
- lastRequestNeighborsTime = lastRequestNeighborsTime,
- onAction = onAction,
- )
- }
-
- ActionsDivider()
-
+ SectionDivider(Modifier.padding(vertical = 8.dp))
ManagementActions(
node = node,
onIgnoreClick = { displayedDialog = DialogType.IGNORE },
@@ -134,28 +115,18 @@ fun DeviceActions(
onRemoveClick = { displayedDialog = DialogType.REMOVE },
)
}
+
+ TelemetricActionsSection(
+ node = node,
+ availableLogs = availableLogs,
+ lastTracerouteTime = lastTracerouteTime,
+ lastRequestNeighborsTime = lastRequestNeighborsTime,
+ metricsState = metricsState,
+ onAction = onAction,
+ )
}
}
-@Composable
-private fun ActionsHeader() {
- 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),
- )
-}
-
-@Composable
-private fun ActionsDivider() {
- HorizontalDivider(
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
- )
-}
-
@Composable
private fun PrimaryActionsRow(
node: Node,
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 0ce76dc87..61a64bdeb 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
@@ -30,11 +30,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer
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
@@ -45,7 +41,6 @@ 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
@@ -56,7 +51,6 @@ import org.meshtastic.core.strings.device
import org.meshtastic.core.strings.hardware
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.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@@ -67,21 +61,9 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
val node = state.node ?: return
val deviceHardware = state.deviceHardware ?: return
- ElevatedCard(
- modifier = modifier.fillMaxWidth(),
- colors = CardDefaults.elevatedCardColors(containerColor = colorScheme.surfaceContainerHigh),
- shape = MaterialTheme.shapes.extraLarge,
- ) {
+ SectionCard(title = Res.string.device, modifier = modifier) {
SelectionContainer {
- 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),
- )
-
+ Column {
Spacer(modifier = Modifier.height(16.dp))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
@@ -90,7 +72,7 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(16.dp))
- InsetDivider()
+ SectionDivider()
val deviceText =
state.reportedTarget?.let { target -> "${deviceHardware.displayName} ($target)" }
?: deviceHardware.displayName
@@ -102,7 +84,7 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
trailingIcon = null,
)
- InsetDivider()
+ SectionDivider()
SupportStatusItem(deviceHardware.activelySupported)
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
index aa531ffb0..fe95adce1 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
@@ -143,13 +143,6 @@ internal fun EnvironmentMetrics(
if (hasWeight()) {
add(VectorMetricInfo(Res.string.weight, "%.2f kg".format(weight), Icons.Default.Scale))
}
- }
- }
- }
- val drawableMetrics =
- remember(node.environmentMetrics, isFahrenheit) {
- buildList {
- with(node.environmentMetrics) {
if (hasTemperature() && hasRelativeHumidity()) {
val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity)
add(
@@ -196,20 +189,21 @@ internal fun EnvironmentMetrics(
verticalArrangement = Arrangement.SpaceEvenly,
) {
vectorMetrics.forEach { metric ->
- InfoCard(
- icon = metric.icon,
- text = stringResource(metric.label),
- value = metric.value,
- rotateIcon = metric.rotateIcon,
- )
- }
- drawableMetrics.forEach { metric ->
- DrawableInfoCard(
- iconRes = metric.icon,
- text = stringResource(metric.label),
- value = metric.value,
- rotateIcon = metric.rotateIcon,
- )
+ if (metric is DrawableMetricInfo) {
+ DrawableInfoCard(
+ iconRes = metric.icon,
+ text = stringResource(metric.label),
+ value = metric.value,
+ rotateIcon = metric.rotateIcon,
+ )
+ } else if (metric is VectorMetricInfo) {
+ InfoCard(
+ icon = metric.icon,
+ text = stringResource(metric.label),
+ value = metric.value,
+ rotateIcon = metric.rotateIcon,
+ )
+ }
}
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/InfoCard.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/InfoCard.kt
index 4bc18b409..95a9e3880 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/InfoCard.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/InfoCard.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,53 +14,104 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.node.component
+import android.content.ClipData
import androidx.annotation.DrawableRes
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.Clipboard
+import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.copy
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable
-fun InfoCard(icon: ImageVector, text: String, value: String, modifier: Modifier = Modifier, rotateIcon: Float = 0f) {
- Card(modifier = modifier.padding(4.dp).width(100.dp).height(100.dp)) {
- Box(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp), contentAlignment = Alignment.Center) {
- Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
+fun InfoCard(
+ text: String,
+ value: String,
+ icon: ImageVector? = null,
+ @DrawableRes iconRes: Int? = null,
+ modifier: Modifier = Modifier,
+ rotateIcon: Float = 0f,
+) {
+ val clipboard: Clipboard = LocalClipboard.current
+ val coroutineScope = rememberCoroutineScope()
+ val shape = MaterialTheme.shapes.medium
+ val copyLabel = stringResource(Res.string.copy)
+
+ Card(
+ modifier =
+ modifier
+ .defaultMinSize(minHeight = 48.dp)
+ .clip(shape)
+ .combinedClickable(
+ onLongClick = {
+ coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(text, value))) }
+ },
+ onLongClickLabel = copyLabel,
+ onClick = {},
+ role = Role.Button,
+ )
+ .semantics(mergeDescendants = true) { contentDescription = "$text: $value" },
+ shape = shape,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
+ ) {
+ Row(
+ modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val iconModifier = Modifier.size(20.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) }
+ val iconTint = MaterialTheme.colorScheme.primary
+ if (icon != null) {
+ Icon(imageVector = icon, contentDescription = null, modifier = iconModifier, tint = iconTint)
+ }
+ if (iconRes != null) {
Icon(
- imageVector = icon,
- contentDescription = text,
- modifier = Modifier.size(24.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) },
+ painter = painterResource(iconRes),
+ contentDescription = null,
+ modifier = iconModifier,
+ tint = iconTint,
)
+ }
+ Column {
Text(
- textAlign = TextAlign.Center,
- text = text,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
+ text,
style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
- text = value,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.titleMedium,
+ value,
+ style = MaterialTheme.typography.labelLargeEmphasized,
+ color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -69,30 +120,7 @@ fun InfoCard(icon: ImageVector, text: String, value: String, modifier: Modifier
@Composable
internal fun DrawableInfoCard(@DrawableRes iconRes: Int, text: String, value: String, rotateIcon: Float = 0f) {
- Card(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp)) {
- Box(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp), contentAlignment = Alignment.Center) {
- Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
- Icon(
- painter = painterResource(id = iconRes),
- contentDescription = text,
- modifier = Modifier.size(24.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) },
- )
- Text(
- textAlign = TextAlign.Center,
- text = text,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.labelSmall,
- )
- Text(
- text = value,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.titleMedium,
- )
- }
- }
- }
+ InfoCard(iconRes = iconRes, text = text, value = value, rotateIcon = rotateIcon)
}
inline fun Modifier.thenIf(precondition: Boolean, action: Modifier.() -> Modifier): Modifier =
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt
index 84a6084e2..4fbc09255 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt
@@ -25,10 +25,16 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
@@ -39,13 +45,13 @@ import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.strings.last_position_update
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.icon
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.formatAgo
-import org.meshtastic.core.ui.util.showToast
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import java.net.URLEncoder
@@ -64,7 +70,22 @@ fun LinkedCoordinatesItem(node: Node, displayUnits: DisplayUnits = DisplayUnits.
" • ${altitude.metersIn(displayUnits).toString(displayUnits)} $suffix"
} ?: ""
+ val copyLabel = stringResource(Res.string.copy)
+
BasicListItem(
+ modifier =
+ Modifier.semantics {
+ role = Role.Button
+ customActions =
+ listOf(
+ CustomAccessibilityAction(copyLabel) {
+ coroutineScope.launch {
+ clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", coordinates)))
+ }
+ true
+ },
+ )
+ },
text = stringResource(Res.string.last_position_update),
leadingIcon = Icons.Default.LocationOn,
supportingText = "$ago • $coordinates$elevationText",
@@ -77,8 +98,6 @@ fun LinkedCoordinatesItem(node: Node, displayUnits: DisplayUnits = DisplayUnits.
try {
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
- } else {
- coroutineScope.launch { context.showToast("No application available to open this location!") }
}
} catch (ex: ActivityNotFoundException) {
Logger.d { "Failed to open geo intent: $ex" }
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
deleted file mode 100644
index f83d8621d..000000000
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * 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.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
-import org.meshtastic.core.strings.Res
-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.feature.node.model.LogsType
-import org.meshtastic.feature.node.model.MetricsState
-import org.meshtastic.feature.node.model.NodeDetailAction
-
-@Composable
-fun MetricsSection(
- node: Node,
- metricsState: MetricsState,
- availableLogs: Set,
- onAction: (NodeDetailAction) -> Unit,
-) {
- if (node.hasEnvironmentMetrics) {
- EnvironmentCard(node, metricsState)
- }
-
- if (node.hasPowerMetrics) {
- PowerCard(node)
- }
-
- val nonPositionLogs = availableLogs.filter { it != LogsType.NODE_MAP && it != LogsType.POSITIONS }
- if (nonPositionLogs.isNotEmpty()) {
- 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)))
- }
- }
- }
- }
-}
-
-@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/NodeDetailComponents.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt
new file mode 100644
index 000000000..7d3c93166
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2025-2026 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.feature.node.component
+
+import android.content.ClipData
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+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.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.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.Clipboard
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.copy
+
+@Composable
+internal fun SectionCard(
+ title: StringResource,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> 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(title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier =
+ Modifier.padding(horizontal = 20.dp, vertical = 8.dp).semantics {
+ heading()
+ }, // Proper navigation for screen reader users
+ )
+ content()
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+internal fun InfoItem(
+ label: String,
+ value: String,
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+ valueStyle: TextStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
+) {
+ val clipboard: Clipboard = LocalClipboard.current
+ val coroutineScope = rememberCoroutineScope()
+ val copyLabel = stringResource(Res.string.copy)
+
+ Column(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 48.dp) // Minimum touch target height
+ .combinedClickable(
+ onLongClick = {
+ coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value))) }
+ },
+ onLongClickLabel = copyLabel, // Clear intent for accessibility
+ onClick = {},
+ role = Role.Button,
+ )
+ .padding(horizontal = 20.dp, vertical = 8.dp)
+ .semantics(mergeDescendants = true) {
+ // Screen readers read as a unified data unit
+ contentDescription = "$label: $value"
+ },
+ ) {
+ 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.Bold,
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(text = value, style = valueStyle, color = MaterialTheme.colorScheme.onSurface)
+ }
+}
+
+@Composable
+internal fun SectionDivider(modifier: Modifier = Modifier) {
+ HorizontalDivider(
+ modifier = modifier.padding(horizontal = 20.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+}
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 1159f445d..65caeaefd 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
@@ -16,93 +16,98 @@
*/
package org.meshtastic.feature.node.component
-import androidx.compose.foundation.layout.Arrangement
+import android.content.ClipData
+import android.util.Base64
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
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.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.KeyOff
+import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Numbers
import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.SignalCellularAlt
+import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Work
-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.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.Clipboard
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.copy
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.hops_away
+import org.meshtastic.core.strings.node_id
import org.meshtastic.core.strings.node_number
import org.meshtastic.core.strings.node_sort_last_heard
+import org.meshtastic.core.strings.public_key
import org.meshtastic.core.strings.role
+import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.short_name
+import org.meshtastic.core.strings.snr
+import org.meshtastic.core.strings.supported
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.user_id
+import org.meshtastic.core.strings.via_mqtt
import org.meshtastic.core.ui.util.formatAgo
@Composable
fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) {
- ElevatedCard(
- modifier = modifier.fillMaxWidth(),
- colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
- shape = MaterialTheme.shapes.extraLarge,
- ) {
- SelectionContainer {
- 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)
+ SectionCard(title = Res.string.details, modifier = modifier) {
+ Column {
+ if (node.mismatchKey) {
+ MismatchKeyWarning(Modifier.padding(horizontal = 16.dp))
+ Spacer(Modifier.height(16.dp))
}
+ MainNodeDetails(node)
}
}
}
@Composable
-private fun MismatchKeyWarning() {
+private fun MismatchKeyWarning(modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.large,
- modifier = Modifier.fillMaxWidth(),
+ 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),
+ contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.width(12.dp))
@@ -125,68 +130,189 @@ private fun MismatchKeyWarning() {
@Composable
private fun MainNodeDetails(node: Node) {
- 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),
- )
+ Column {
+ NameAndRoleRow(node)
+ SectionDivider()
+ NodeIdentificationRow(node)
+ SectionDivider()
+ HearsAndHopsRow(node)
+ SectionDivider()
+ UserAndUptimeRow(node)
+ SectionDivider()
+ SignalRow(node)
+ if (node.viaMqtt || node.manuallyVerified) {
+ SectionDivider()
+ MqttAndVerificationRow(node)
}
-
- HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
-
- 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),
- )
- }
-
- HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
-
- 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))
- }
+ val publicKey = node.publicKey ?: node.user.publicKey
+ if (!publicKey.isEmpty) {
+ SectionDivider()
+ PublicKeyItem(publicKey.toByteArray())
}
}
}
@Composable
-private fun InfoItem(label: String, value: String, icon: ImageVector, modifier: Modifier = Modifier) {
- Column(modifier = modifier) {
+private fun NameAndRoleRow(node: Node) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ 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),
+ )
+ }
+}
+
+@Composable
+private fun NodeIdentificationRow(node: Node) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ InfoItem(
+ label = stringResource(Res.string.node_id),
+ value = DataPacket.nodeNumToDefaultId(node.num),
+ icon = Icons.Default.Numbers,
+ modifier = Modifier.weight(1f),
+ )
+ InfoItem(
+ label = stringResource(Res.string.node_number),
+ value = node.num.toUInt().toString(),
+ icon = Icons.Default.Numbers,
+ modifier = Modifier.weight(1f),
+ )
+ }
+}
+
+@Composable
+private fun HearsAndHopsRow(node: Node) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ InfoItem(
+ label = stringResource(Res.string.node_sort_last_heard),
+ value = formatAgo(node.lastHeard),
+ icon = Icons.Default.History,
+ modifier = Modifier.weight(1f),
+ )
+ if (node.hopsAway >= 0) {
+ InfoItem(
+ label = stringResource(Res.string.hops_away),
+ value = node.hopsAway.toString(),
+ icon = Icons.Default.SignalCellularAlt,
+ modifier = Modifier.weight(1f),
+ )
+ } else {
+ Spacer(Modifier.weight(1f))
+ }
+ }
+}
+
+@Composable
+private fun UserAndUptimeRow(node: Node) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ 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 SignalRow(node: Node) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ if (node.snr != Float.MAX_VALUE) {
+ InfoItem(
+ label = stringResource(Res.string.snr),
+ value = "%.1f dB".format(node.snr),
+ icon = Icons.Default.SignalCellularAlt,
+ modifier = Modifier.weight(1f),
+ )
+ } else {
+ Spacer(Modifier.weight(1f))
+ }
+ if (node.rssi != Int.MAX_VALUE) {
+ InfoItem(
+ label = stringResource(Res.string.rssi),
+ value = "%d dBm".format(node.rssi),
+ icon = Icons.Default.SignalCellularAlt,
+ modifier = Modifier.weight(1f),
+ )
+ } else {
+ Spacer(Modifier.weight(1f))
+ }
+ }
+}
+
+@Composable
+private fun MqttAndVerificationRow(node: Node) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ if (node.viaMqtt) {
+ InfoItem(
+ label = stringResource(Res.string.via_mqtt),
+ value = "Yes",
+ icon = Icons.Default.Cloud,
+ modifier = Modifier.weight(1f),
+ )
+ } else {
+ Spacer(Modifier.weight(1f))
+ }
+ if (node.manuallyVerified) {
+ InfoItem(
+ label = stringResource(Res.string.supported),
+ value = "Verified",
+ icon = Icons.Default.Verified,
+ modifier = Modifier.weight(1f),
+ )
+ } else {
+ Spacer(Modifier.weight(1f))
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun PublicKeyItem(publicKeyBytes: ByteArray) {
+ val clipboard: Clipboard = LocalClipboard.current
+ val coroutineScope = rememberCoroutineScope()
+ val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.DEFAULT).trim()
+ val label = stringResource(Res.string.public_key)
+ val copyLabel = stringResource(Res.string.copy)
+
+ Column(
+ modifier =
+ Modifier.fillMaxWidth()
+ .defaultMinSize(minHeight = 48.dp)
+ .combinedClickable(
+ onLongClick = {
+ coroutineScope.launch {
+ clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, publicKeyBase64)))
+ }
+ },
+ onLongClickLabel = copyLabel,
+ onClick = {},
+ role = Role.Button,
+ )
+ .padding(horizontal = 20.dp, vertical = 8.dp)
+ .semantics(mergeDescendants = true) { contentDescription = "$label: $publicKeyBase64" },
+ ) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
- imageVector = icon,
+ imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
@@ -196,14 +322,13 @@ private fun InfoItem(label: String, value: String, icon: ImageVector, modifier:
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- fontWeight = FontWeight.Medium,
+ fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(4.dp))
Text(
- text = value,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.SemiBold,
+ text = publicKeyBase64,
+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface,
)
}
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 09446e4bb..ebf0f567f 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
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement
@@ -36,8 +35,6 @@ 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
@@ -46,7 +43,6 @@ 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
@@ -61,6 +57,9 @@ import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
+private const val EXCHANGE_BUTTON_WEIGHT = 1.1f
+private const val COMPASS_BUTTON_WEIGHT = 0.9f
+
/**
* Displays node position details, last update time, distance, and related actions like requesting position and
* accessing map/position logs.
@@ -76,21 +75,9 @@ fun PositionSection(
) {
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits)
val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0
- 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,
- )
-
- Spacer(Modifier.height(16.dp))
+ SectionCard(title = Res.string.position, modifier = modifier) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
if (hasValidPosition) {
PositionMap(node, distance)
LinkedCoordinatesItem(node, metricsState.displayUnits)
@@ -168,7 +155,7 @@ private fun PositionActionButtons(
) {
Button(
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
- modifier = Modifier.weight(1f),
+ modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT),
shape = MaterialTheme.shapes.large,
colors =
ButtonDefaults.buttonColors(
@@ -176,20 +163,30 @@ private fun PositionActionButtons(
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)
+ Icon(Icons.Default.LocationOn, null, Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = stringResource(Res.string.exchange_position),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Visible,
+ )
}
if (hasValidPosition) {
FilledTonalButton(
onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
- modifier = Modifier.weight(1f),
+ modifier = Modifier.weight(COMPASS_BUTTON_WEIGHT),
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)
+ Icon(Icons.Default.Explore, null, Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = stringResource(Res.string.open_compass),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
}
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt
index 221c75258..01c032942 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,11 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
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.material.icons.Icons
@@ -65,15 +63,11 @@ internal fun PowerMetrics(node: Node) {
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
+ horizontalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.SpaceEvenly,
) {
- metrics.chunked(2).forEach { rowMetrics ->
- Column {
- rowMetrics.forEach { metric ->
- InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value)
- }
- }
+ metrics.forEach { metric ->
+ InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value)
}
}
}
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
deleted file mode 100644
index 2e7b27947..000000000
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt
+++ /dev/null
@@ -1,152 +0,0 @@
-/*
- * Copyright (c) 2025-2026 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.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.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.exchange_userinfo
-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
-
-@Composable
-@Suppress("LongMethod")
-internal fun RemoteDeviceActions(
- node: Node,
- lastTracerouteTime: Long?,
- lastRequestNeighborsTime: Long?,
- onAction: (NodeDetailAction) -> Unit,
-) {
- 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),
- )
-
- 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))) },
- )
-
- if (node.capabilities.canRequestNeighborInfo) {
- 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)) },
- )
- }
- }
-}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
new file mode 100644
index 000000000..ed9c47825
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
@@ -0,0 +1,272 @@
+/*
+ * Copyright (c) 2025-2026 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.feature.node.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.Air
+import androidx.compose.material.icons.filled.Groups
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Speed
+import androidx.compose.material.icons.filled.StackedLineChart
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.StringResource
+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.logs
+import org.meshtastic.core.strings.neighbor_info
+import org.meshtastic.core.strings.request_air_quality_metrics
+import org.meshtastic.core.strings.request_local_stats
+import org.meshtastic.core.strings.request_telemetry
+import org.meshtastic.core.strings.telemetry
+import org.meshtastic.core.strings.userinfo
+import org.meshtastic.feature.node.model.LogsType
+import org.meshtastic.feature.node.model.MetricsState
+import org.meshtastic.feature.node.model.NodeDetailAction
+
+private data class TelemetricFeature(
+ val titleRes: StringResource,
+ val icon: ImageVector,
+ val requestAction: ((Node) -> NodeMenuAction)?,
+ val logsType: LogsType? = null,
+ val isVisible: (Node) -> Boolean = { true },
+ val cooldownTimestamp: Long? = null,
+ val cooldownDuration: Long = COOL_DOWN_TIME_MS,
+ val content: @Composable ((Node) -> Unit)? = null,
+ val hasContent: (Node) -> Boolean = { false },
+)
+
+@Composable
+internal fun TelemetricActionsSection(
+ node: Node,
+ availableLogs: Set,
+ lastTracerouteTime: Long?,
+ lastRequestNeighborsTime: Long?,
+ metricsState: MetricsState,
+ onAction: (NodeDetailAction) -> Unit,
+) {
+ val features = rememberTelemetricFeatures(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState)
+
+ SectionCard(title = Res.string.telemetry) {
+ features
+ .filter { it.isVisible(node) }
+ .forEachIndexed { index, feature ->
+ if (index > 0) {
+ SectionDivider()
+ }
+ FeatureRow(
+ node = node,
+ feature = feature,
+ hasLogs = feature.logsType != null && availableLogs.contains(feature.logsType),
+ onAction = onAction,
+ )
+ }
+ }
+}
+
+@Suppress("LongMethod")
+@Composable
+private fun rememberTelemetricFeatures(
+ node: Node,
+ lastTracerouteTime: Long?,
+ lastRequestNeighborsTime: Long?,
+ metricsState: MetricsState,
+): List = remember(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState) {
+ listOf(
+ TelemetricFeature(
+ titleRes = Res.string.userinfo,
+ icon = Icons.Default.Person,
+ requestAction = { NodeMenuAction.RequestUserInfo(it) },
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.TRACEROUTE.titleRes,
+ icon = LogsType.TRACEROUTE.icon,
+ requestAction = { NodeMenuAction.TraceRoute(it) },
+ logsType = LogsType.TRACEROUTE,
+ cooldownTimestamp = lastTracerouteTime,
+ ),
+ TelemetricFeature(
+ titleRes = Res.string.neighbor_info,
+ icon = Icons.Default.Groups,
+ requestAction = { NodeMenuAction.RequestNeighborInfo(it) },
+ isVisible = { it.capabilities.canRequestNeighborInfo },
+ cooldownTimestamp = lastRequestNeighborsTime,
+ cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS,
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.DEVICE.titleRes,
+ icon = LogsType.DEVICE.icon,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) },
+ logsType = LogsType.DEVICE,
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.ENVIRONMENT.titleRes,
+ icon = Icons.Default.Air,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) },
+ logsType = LogsType.ENVIRONMENT,
+ content = { EnvironmentMetrics(it, metricsState.displayUnits, metricsState.isFahrenheit) },
+ hasContent = { it.hasEnvironmentMetrics },
+ ),
+ TelemetricFeature(
+ titleRes = Res.string.request_air_quality_metrics,
+ icon = Icons.Default.Air,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) },
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.POWER.titleRes,
+ icon = LogsType.POWER.icon,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) },
+ logsType = LogsType.POWER,
+ content = { PowerMetrics(it) },
+ hasContent = { it.hasPowerMetrics },
+ ),
+ TelemetricFeature(
+ titleRes = Res.string.request_local_stats,
+ icon = Icons.Default.Speed,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.HOST.titleRes,
+ icon = LogsType.HOST.icon,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) },
+ logsType = LogsType.HOST,
+ ),
+ TelemetricFeature(
+ titleRes = LogsType.PAX.titleRes,
+ icon = LogsType.PAX.icon,
+ requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) },
+ logsType = LogsType.PAX,
+ ),
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Suppress("LongMethod")
+@Composable
+private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean, onAction: (NodeDetailAction) -> Unit) {
+ val showContent = feature.content != null && feature.hasContent(node)
+ val description = stringResource(feature.titleRes)
+ val logsDescription = description + " " + stringResource(Res.string.logs)
+ val requestDescription = description + " " + stringResource(Res.string.request_telemetry)
+
+ Column {
+ ListItem(
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ leadingContent = {
+ Icon(imageVector = feature.icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+ },
+ headlineContent = {
+ Text(
+ text = stringResource(feature.titleRes),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ },
+ trailingContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ AnimatedVisibility(visible = hasLogs) {
+ TooltipBox(
+ positionProvider =
+ TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
+ tooltip = { PlainTooltip { Text(logsDescription) } },
+ state = rememberTooltipState(),
+ ) {
+ FilledTonalIconButton(
+ shapes = IconButtonDefaults.shapes(),
+ colors = IconButtonDefaults.filledTonalIconButtonColors(),
+ onClick = {
+ feature.logsType?.let {
+ onAction(NodeDetailAction.Navigate(it.routeFactory(node.num)))
+ }
+ },
+ ) {
+ Icon(
+ Icons.Default.StackedLineChart,
+ contentDescription = logsDescription,
+ modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+
+ if (feature.requestAction != null) {
+ if (hasLogs) Spacer(modifier = Modifier.width(8.dp))
+ TooltipBox(
+ positionProvider =
+ TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
+ tooltip = { PlainTooltip { Text(requestDescription) } },
+ state = rememberTooltipState(),
+ ) {
+ CooldownOutlinedIconButton(
+ onClick = {
+ val menuAction = feature.requestAction.invoke(node)
+ onAction(NodeDetailAction.HandleNodeMenuAction(menuAction))
+ },
+ cooldownTimestamp = feature.cooldownTimestamp,
+ cooldownDuration = feature.cooldownDuration,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = requestDescription,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ },
+ )
+
+ if (showContent) {
+ Column(modifier = Modifier.padding(start = 56.dp, end = 20.dp, bottom = 12.dp)) {
+ feature.content?.invoke(node)
+ }
+ }
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt
index 8c7051ef8..092eb5a4d 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt
@@ -30,50 +30,47 @@ constructor(
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
) {
- private var scope: CoroutineScope? = null
-
- fun start(coroutineScope: CoroutineScope) {
- scope = coroutineScope
- nodeManagementActions.start(coroutineScope)
- nodeRequestActions.start(coroutineScope)
- }
-
- fun handleNodeMenuAction(action: NodeMenuAction) {
+ fun handleNodeMenuAction(scope: CoroutineScope, action: NodeMenuAction) {
when (action) {
- is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num)
- is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node)
- is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node)
- is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node)
- is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num)
- is NodeMenuAction.RequestNeighborInfo -> nodeRequestActions.requestNeighborInfo(action.node.num)
- is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num)
- is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type)
- is NodeMenuAction.TraceRoute -> nodeRequestActions.requestTraceroute(action.node.num)
+ is NodeMenuAction.Remove -> nodeManagementActions.removeNode(scope, action.node.num)
+ is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(scope, action.node)
+ is NodeMenuAction.Mute -> nodeManagementActions.muteNode(scope, action.node)
+ is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(scope, action.node)
+ is NodeMenuAction.RequestUserInfo ->
+ nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.longName)
+ is NodeMenuAction.RequestNeighborInfo ->
+ nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.longName)
+ is NodeMenuAction.RequestPosition ->
+ nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.longName)
+ is NodeMenuAction.RequestTelemetry ->
+ nodeRequestActions.requestTelemetry(scope, action.node.num, action.node.user.longName, action.type)
+ is NodeMenuAction.TraceRoute ->
+ nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.longName)
else -> {}
}
}
- fun setNodeNotes(nodeNum: Int, notes: String) {
- nodeManagementActions.setNodeNotes(nodeNum, notes)
+ fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) {
+ nodeManagementActions.setNodeNotes(scope, nodeNum, notes)
}
- fun requestPosition(destNum: Int, position: Position) {
- nodeRequestActions.requestPosition(destNum, position)
+ fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) {
+ nodeRequestActions.requestPosition(scope, destNum, longName, position)
}
- fun requestUserInfo(destNum: Int) {
- nodeRequestActions.requestUserInfo(destNum)
+ fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
+ nodeRequestActions.requestUserInfo(scope, destNum, longName)
}
- fun requestNeighborInfo(destNum: Int) {
- nodeRequestActions.requestNeighborInfo(destNum)
+ fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
+ nodeRequestActions.requestNeighborInfo(scope, destNum, longName)
}
- fun requestTelemetry(destNum: Int, type: TelemetryType) {
- nodeRequestActions.requestTelemetry(destNum, type)
+ fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
+ nodeRequestActions.requestTelemetry(scope, destNum, longName, type)
}
- fun requestTraceroute(destNum: Int) {
- nodeRequestActions.requestTraceroute(destNum)
+ fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
+ nodeRequestActions.requestTraceroute(scope, destNum, longName)
}
}
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 04e8c896d..d8ac5f1af 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
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.node.detail
import android.Manifest
@@ -59,7 +58,6 @@ import org.meshtastic.feature.node.component.CompassSheetContent
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
-import org.meshtastic.feature.node.component.MetricsSection
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.component.NotesSection
@@ -126,7 +124,7 @@ fun NodeDetailList(
val compassViewModel = if (inspectionMode) null else hiltViewModel()
val compassUiState by
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
- var compassTargetNode by remember { mutableStateOf(null) } // Cache target for sheet-side position requests
+ var compassTargetNode by remember { mutableStateOf(null) }
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
@@ -164,7 +162,9 @@ fun NodeDetailList(
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
node = node,
+ availableLogs = availableLogs,
onAction = onAction,
+ metricsState = metricsState,
)
PositionSection(
@@ -189,8 +189,6 @@ fun NodeDetailList(
DeviceDetailsSection(metricsState)
}
- MetricsSection(node, metricsState, availableLogs, onAction)
-
NotesSection(node = node, onSaveNotes = onSaveNotes)
if (!metricsState.isManaged) {
@@ -231,7 +229,6 @@ private fun CompassSheetHost(
onRequestPosition: () -> Unit,
) {
if (showCompassSheet && compassViewModel != null) {
- // Tie sensor lifecycle to the sheet so streams stop as soon as the sheet is dismissed.
DisposableEffect(Unit) { onDispose { compassViewModel.stop() } }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailPresenter.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailPresenter.kt
new file mode 100644
index 000000000..8914745cf
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailPresenter.kt
@@ -0,0 +1,233 @@
+/*
+ * Copyright (c) 2025-2026 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.feature.node.detail
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import com.meshtastic.core.strings.getString
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import org.meshtastic.core.data.repository.DeviceHardwareRepository
+import org.meshtastic.core.data.repository.FirmwareReleaseRepository
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.database.entity.FirmwareRelease
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.fallback_node_name
+import org.meshtastic.core.ui.util.toPosition
+import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
+import org.meshtastic.feature.node.metrics.safeNumber
+import org.meshtastic.feature.node.model.LogsType
+import org.meshtastic.feature.node.model.MetricsState
+import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile
+import org.meshtastic.proto.ConfigProtos.Config
+import org.meshtastic.proto.MeshProtos
+import org.meshtastic.proto.Portnums.PortNum
+
+private const val DEFAULT_ID_SUFFIX_LENGTH = 4
+
+@Composable
+@Suppress("LongMethod", "FunctionName")
+fun NodeDetailPresenter(
+ nodeId: Int?,
+ nodeRepository: NodeRepository,
+ meshLogRepository: MeshLogRepository,
+ radioConfigRepository: RadioConfigRepository,
+ deviceHardwareRepository: DeviceHardwareRepository,
+ firmwareReleaseRepository: FirmwareReleaseRepository,
+ nodeRequestActions: NodeRequestActions,
+): NodeDetailUiState {
+ if (nodeId == null) return NodeDetailUiState()
+
+ val ourNode by nodeRepository.ourNodeInfo.collectAsState(null)
+ val ourNodeNum by remember { nodeRepository.nodeDBbyNum.map { it.keys.firstOrNull() } }.collectAsState(null)
+
+ val specificNode by remember(nodeId) { nodeRepository.nodeDBbyNum.map { it[nodeId] } }.collectAsState(null)
+
+ val myInfo by nodeRepository.myNodeInfo.collectAsState(null)
+ val profile by radioConfigRepository.deviceProfileFlow.collectAsState(DeviceProfile.getDefaultInstance())
+
+ val telemetry by remember(nodeId) { meshLogRepository.getTelemetryFrom(nodeId) }.collectAsState(emptyList())
+ val packets by remember(nodeId) { meshLogRepository.getMeshPacketsFrom(nodeId) }.collectAsState(emptyList())
+ val positionPackets by
+ remember(nodeId) { meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP_VALUE) }
+ .collectAsState(emptyList())
+ val paxLogs by
+ remember(nodeId) { meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP_VALUE) }
+ .collectAsState(emptyList())
+
+ val tracerouteRequests by
+ remember(nodeId) {
+ meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE).map { logs ->
+ logs.filter { log ->
+ with(log.fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == nodeId }
+ }
+ }
+ }
+ .collectAsState(emptyList())
+
+ val tracerouteResults by
+ remember(nodeId) { meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP_VALUE) }
+ .collectAsState(emptyList())
+
+ val firmwareEdition by
+ remember { meshLogRepository.getMyNodeInfo().map { it?.firmwareEdition }.distinctUntilChanged() }
+ .collectAsState(null)
+
+ val stable by firmwareReleaseRepository.stableRelease.collectAsState(null)
+ val alpha by firmwareReleaseRepository.alphaRelease.collectAsState(null)
+
+ val lastTracerouteTime by nodeRequestActions.lastTracerouteTimes.collectAsState(emptyMap())
+ val lastRequestNeighborsTime by nodeRequestActions.lastRequestNeighborTimes.collectAsState(emptyMap())
+
+ val fallbackNameString = remember { getString(Res.string.fallback_node_name) }
+
+ val metricsState =
+ remember(
+ specificNode,
+ ourNodeNum,
+ myInfo,
+ profile,
+ telemetry,
+ packets,
+ positionPackets,
+ paxLogs,
+ tracerouteRequests,
+ tracerouteResults,
+ firmwareEdition,
+ stable,
+ alpha,
+ nodeId,
+ fallbackNameString, // Dependency for fallback creation
+ ) {
+ val actualNode = specificNode ?: createFallbackNode(nodeId, fallbackNameString)
+ val pioEnv = if (nodeId == ourNodeNum) myInfo?.pioEnv else null
+
+ val moduleConfig = profile.moduleConfig
+ val displayUnits = profile.config.display.units
+
+ Triple(actualNode, pioEnv, moduleConfig to displayUnits)
+ }
+
+ val (actualNode, pioEnv, configPair) = metricsState
+ val (moduleConfig, displayUnits) = configPair
+
+ val deviceHardware by
+ produceState(initialValue = null, key1 = actualNode.user.hwModel, key2 = pioEnv) {
+ val hwModel = actualNode.user.hwModel.safeNumber()
+ value = deviceHardwareRepository.getDeviceHardwareByModel(hwModel, target = pioEnv).getOrNull()
+ }
+
+ val finalMetricsState =
+ remember(
+ metricsState, // triggers when actualNode or pioEnv or configs change
+ deviceHardware,
+ telemetry,
+ packets,
+ positionPackets,
+ paxLogs,
+ tracerouteRequests,
+ tracerouteResults,
+ firmwareEdition,
+ stable,
+ alpha,
+ ) {
+ MetricsState(
+ node = actualNode,
+ isLocal = nodeId == ourNodeNum,
+ deviceHardware = deviceHardware,
+ reportedTarget = pioEnv,
+ isManaged = profile.config.security.isManaged,
+ isFahrenheit =
+ moduleConfig.telemetry.environmentDisplayFahrenheit ||
+ (displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
+ displayUnits = displayUnits,
+ deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
+ powerMetrics = telemetry.filter { it.hasPowerMetrics() },
+ hostMetrics = telemetry.filter { it.hasHostMetrics() },
+ signalMetrics = packets.filter { it.rxTime > 0 },
+ positionLogs = positionPackets.mapNotNull { it.toPosition() },
+ paxMetrics = paxLogs,
+ tracerouteRequests = tracerouteRequests,
+ tracerouteResults = tracerouteResults,
+ firmwareEdition = firmwareEdition,
+ latestStableFirmware = stable ?: FirmwareRelease(),
+ latestAlphaFirmware = alpha ?: FirmwareRelease(),
+ )
+ }
+
+ val environmentState =
+ remember(telemetry) {
+ EnvironmentMetricsState(
+ environmentMetrics =
+ telemetry.filter {
+ it.hasEnvironmentMetrics() &&
+ it.environmentMetrics.hasRelativeHumidity() &&
+ it.environmentMetrics.hasTemperature() &&
+ !it.environmentMetrics.temperature.isNaN()
+ },
+ )
+ }
+
+ val availableLogs =
+ remember(finalMetricsState, environmentState) { getAvailableLogs(finalMetricsState, environmentState) }
+
+ return NodeDetailUiState(
+ node = finalMetricsState.node,
+ ourNode = ourNode,
+ metricsState = finalMetricsState,
+ environmentState = environmentState,
+ availableLogs = availableLogs,
+ lastTracerouteTime = lastTracerouteTime[nodeId],
+ lastRequestNeighborsTime = lastRequestNeighborsTime[nodeId],
+ )
+}
+
+private fun createFallbackNode(nodeNum: Int, fallbackName: String): Node {
+ val userId = DataPacket.nodeNumToDefaultId(nodeNum)
+ val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH)
+ val longName = "$fallbackName $safeUserId"
+ val defaultUser =
+ MeshProtos.User.newBuilder()
+ .setId(userId)
+ .setLongName(longName)
+ .setShortName(safeUserId)
+ .setHwModel(MeshProtos.HardwareModel.UNSET)
+ .build()
+ return Node(num = nodeNum, user = defaultUser)
+}
+
+private fun getAvailableLogs(metricsState: MetricsState, envState: EnvironmentMetricsState): Set = buildSet {
+ if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
+ if (metricsState.hasPositionLogs()) {
+ add(LogsType.NODE_MAP)
+ add(LogsType.POSITIONS)
+ }
+ if (envState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
+ if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
+ if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
+ if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
+ if (metricsState.hasHostMetrics()) add(LogsType.HOST)
+ if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
index 9cbd66e26..4eb65761d 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
@@ -16,75 +16,136 @@
*/
package org.meshtastic.feature.node.detail
+import android.Manifest
+import android.content.Intent
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
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.platform.LocalInspectionMode
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.meshtastic.core.strings.getString
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.navigation.Route
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.loading
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.core.ui.component.SharedContactDialog
+import org.meshtastic.feature.node.compass.CompassUiState
+import org.meshtastic.feature.node.compass.CompassViewModel
+import org.meshtastic.feature.node.component.AdministrationSection
+import org.meshtastic.feature.node.component.CompassSheetContent
+import org.meshtastic.feature.node.component.DeviceActions
+import org.meshtastic.feature.node.component.DeviceDetailsSection
+import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
+import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NodeMenuAction
-import org.meshtastic.feature.node.metrics.MetricsViewModel
-import org.meshtastic.feature.node.model.LogsType
+import org.meshtastic.feature.node.component.NotesSection
+import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.NodeDetailAction
-@Suppress("LongMethod")
+private sealed interface NodeDetailOverlay {
+ data object SharedContact : NodeDetailOverlay
+
+ data class FirmwareReleaseInfo(val release: FirmwareRelease) : NodeDetailOverlay
+
+ data object Compass : NodeDetailOverlay
+}
+
@Composable
fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
- metricsViewModel: MetricsViewModel = hiltViewModel(),
- nodeDetailViewModel: NodeDetailViewModel = hiltViewModel(),
+ viewModel: NodeDetailViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
- LaunchedEffect(nodeId) { metricsViewModel.setNodeId(nodeId) }
+ viewModel.start(nodeId)
- val metricsState by metricsViewModel.state.collectAsStateWithLifecycle()
- val environmentMetricsState by metricsViewModel.environmentState.collectAsStateWithLifecycle()
- val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
- val lastRequestNeighborsTime by nodeDetailViewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle()
- val ourNode by nodeDetailViewModel.ourNodeInfo.collectAsStateWithLifecycle()
-
- val availableLogs by
- remember(metricsState, environmentMetricsState) {
- derivedStateOf {
- buildSet {
- if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
- if (metricsState.hasPositionLogs()) {
- add(LogsType.NODE_MAP)
- add(LogsType.POSITIONS)
- }
- if (environmentMetricsState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
- if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
- if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
- if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
- if (metricsState.hasHostMetrics()) add(LogsType.HOST)
- if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
- }
+ val snackbarHostState = remember { SnackbarHostState() }
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ if (effect is NodeRequestEffect.ShowFeedback) {
+ @Suppress("SpreadOperator")
+ snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
+ }
- val node = metricsState.node
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ NodeDetailScaffold(
+ modifier = modifier,
+ uiState = uiState,
+ snackbarHostState = snackbarHostState,
+ viewModel = viewModel,
+ navigateToMessages = navigateToMessages,
+ onNavigate = onNavigate,
+ onNavigateUp = onNavigateUp,
+ )
+}
+
+@Composable
+@Suppress("LongParameterList")
+private fun NodeDetailScaffold(
+ modifier: Modifier,
+ uiState: NodeDetailUiState,
+ snackbarHostState: SnackbarHostState,
+ viewModel: NodeDetailViewModel,
+ navigateToMessages: (String) -> Unit,
+ onNavigate: (Route) -> Unit,
+ onNavigateUp: () -> Unit,
+) {
+ var activeOverlay by remember { mutableStateOf(null) }
+ val inspectionMode = LocalInspectionMode.current
+ val compassViewModel = if (inspectionMode) null else hiltViewModel()
+ val compassUiState by
+ compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
+
+ val node = uiState.node
+ val scrollState = rememberScrollState()
- @Suppress("ModifierNotUsedAtRoot")
Scaffold(
+ modifier = modifier,
topBar = {
MainAppBar(
title = node?.user?.longName ?: "",
- ourNode = ourNode,
+ ourNode = uiState.ourNode,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
@@ -92,74 +153,188 @@ fun NodeDetailScreen(
onClickChip = {},
)
},
+ snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
- if (node != null) {
- NodeDetailContent(
- node = node,
- ourNode = ourNode,
- metricsState = metricsState,
- lastTracerouteTime = lastTracerouteTime,
- lastRequestNeighborsTime = lastRequestNeighborsTime,
- availableLogs = availableLogs,
- onAction = { action ->
- handleNodeAction(
- action = action,
- ourNode = ourNode,
- node = node,
- navigateToMessages = navigateToMessages,
- onNavigateUp = onNavigateUp,
- onNavigate = onNavigate,
- metricsViewModel = metricsViewModel,
- nodeDetailViewModel = nodeDetailViewModel,
- )
- },
- modifier = modifier.padding(paddingValues),
- onSaveNotes = { num, notes -> nodeDetailViewModel.setNodeNotes(num, notes) },
+ NodeDetailContent(
+ uiState = uiState,
+ viewModel = viewModel,
+ scrollState = scrollState,
+ onAction = { action ->
+ when (action) {
+ is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
+ is NodeDetailAction.OpenCompass -> {
+ compassViewModel?.start(action.node, action.displayUnits)
+ activeOverlay = NodeDetailOverlay.Compass
+ }
+ else ->
+ handleNodeAction(
+ action = action,
+ uiState = uiState,
+ navigateToMessages = navigateToMessages,
+ onNavigateUp = onNavigateUp,
+ onNavigate = onNavigate,
+ viewModel = viewModel,
+ )
+ }
+ },
+ onFirmwareSelect = { activeOverlay = NodeDetailOverlay.FirmwareReleaseInfo(it) },
+ modifier = Modifier.padding(paddingValues),
+ )
+ }
+
+ NodeDetailOverlays(activeOverlay, node, compassUiState, compassViewModel, { activeOverlay = null }) {
+ viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
+ }
+}
+
+@Composable
+private fun NodeDetailContent(
+ uiState: NodeDetailUiState,
+ viewModel: NodeDetailViewModel,
+ scrollState: ScrollState,
+ onAction: (NodeDetailAction) -> Unit,
+ onFirmwareSelect: (FirmwareRelease) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ AnimatedContent(
+ targetState = uiState.node != null,
+ transitionSpec = { fadeIn().togetherWith(fadeOut()) },
+ label = "NodeDetailContent",
+ modifier = modifier,
+ ) { isNodePresent ->
+ if (isNodePresent && uiState.node != null) {
+ NodeDetailList(
+ node = uiState.node,
+ ourNode = uiState.ourNode,
+ uiState = uiState,
+ scrollState = scrollState,
+ onAction = onAction,
+ onFirmwareSelect = onFirmwareSelect,
+ onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
)
} else {
- Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) {
- CircularProgressIndicator()
+ val loadingDescription = stringResource(Res.string.loading)
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(modifier = Modifier.semantics { contentDescription = loadingDescription })
}
}
}
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun NodeDetailOverlays(
+ overlay: NodeDetailOverlay?,
+ node: Node?,
+ compassUiState: CompassUiState,
+ compassViewModel: CompassViewModel?,
+ onDismiss: () -> Unit,
+ onRequestPosition: (Node) -> Unit,
+) {
+ val permissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
+ val locationSettingsLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> }
+
+ when (overlay) {
+ is NodeDetailOverlay.SharedContact -> node?.let { SharedContactDialog(it, onDismiss) }
+ is NodeDetailOverlay.FirmwareReleaseInfo ->
+ NodeDetailBottomSheet(onDismiss) { FirmwareReleaseSheetContent(firmwareRelease = overlay.release) }
+ is NodeDetailOverlay.Compass -> {
+ DisposableEffect(Unit) { onDispose { compassViewModel?.stop() } }
+ NodeDetailBottomSheet(
+ onDismiss = {
+ compassViewModel?.stop()
+ onDismiss()
+ },
+ ) {
+ CompassSheetContent(
+ uiState = compassUiState,
+ onRequestLocationPermission = {
+ val perms =
+ arrayOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ )
+ permissionLauncher.launch(perms)
+ },
+ onOpenLocationSettings = {
+ locationSettingsLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
+ },
+ onRequestPosition = { node?.let { onRequestPosition(it) } },
+ modifier = Modifier.padding(bottom = 24.dp),
+ )
+ }
+ }
+ null -> {}
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable () -> Unit) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
+}
+
+@Composable
+private fun NodeDetailList(
+ node: Node,
+ ourNode: Node?,
+ uiState: NodeDetailUiState,
+ scrollState: ScrollState,
+ onAction: (NodeDetailAction) -> Unit,
+ onFirmwareSelect: (FirmwareRelease) -> Unit,
+ onSaveNotes: (Int, String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxSize().verticalScroll(scrollState).padding(16.dp).focusable(),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ NodeDetailsSection(node)
+ DeviceActions(
+ node = node,
+ lastTracerouteTime = uiState.lastTracerouteTime,
+ lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
+ availableLogs = uiState.availableLogs,
+ onAction = onAction,
+ metricsState = uiState.metricsState,
+ isLocal = uiState.metricsState.isLocal,
+ )
+ PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction)
+ if (uiState.metricsState.deviceHardware != null) DeviceDetailsSection(uiState.metricsState)
+ NotesSection(node = node, onSaveNotes = onSaveNotes)
+ if (!uiState.metricsState.isManaged) {
+ AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect)
+ }
+ }
+}
+
private fun handleNodeAction(
action: NodeDetailAction,
- ourNode: Node?,
- node: Node,
+ uiState: NodeDetailUiState,
navigateToMessages: (String) -> Unit,
onNavigateUp: () -> Unit,
onNavigate: (Route) -> Unit,
- metricsViewModel: MetricsViewModel,
- nodeDetailViewModel: NodeDetailViewModel,
+ viewModel: NodeDetailViewModel,
) {
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
- is NodeDetailAction.TriggerServiceAction -> metricsViewModel.onServiceAction(action.action)
+ is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
- val hasPKC = ourNode?.hasPKC == true
- val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
- navigateToMessages("${channel}${node.user.id}")
+ val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
+ navigateToMessages(route)
}
-
is NodeMenuAction.Remove -> {
- nodeDetailViewModel.handleNodeMenuAction(menuAction)
+ viewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
-
- else -> nodeDetailViewModel.handleNodeMenuAction(menuAction)
+ else -> viewModel.handleNodeMenuAction(menuAction)
}
}
-
- is NodeDetailAction.ShareContact -> {
- /* Handled in NodeDetailContent */
- }
-
- is NodeDetailAction.OpenCompass -> {
- /* Handled in NodeDetailList */
- }
+ else -> {}
}
}
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 19db2b430..dfa25928f 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
@@ -16,61 +16,128 @@
*/
package org.meshtastic.feature.node.detail
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import androidx.navigation.toRoute
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.launchMolecule
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
+import org.meshtastic.core.data.repository.DeviceHardwareRepository
+import org.meshtastic.core.data.repository.FirmwareReleaseRepository
+import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.navigation.NodesRoutes
+import org.meshtastic.core.service.ServiceAction
+import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.feature.node.component.NodeMenuAction
+import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
+import org.meshtastic.feature.node.model.LogsType
+import org.meshtastic.feature.node.model.MetricsState
import javax.inject.Inject
+data class NodeDetailUiState(
+ val node: Node? = null,
+ val ourNode: Node? = null,
+ val metricsState: MetricsState = MetricsState.Empty,
+ val environmentState: EnvironmentMetricsState = EnvironmentMetricsState(),
+ val availableLogs: Set = emptySet(),
+ val lastTracerouteTime: Long? = null,
+ val lastRequestNeighborsTime: Long? = null,
+)
+
@HiltViewModel
class NodeDetailViewModel
@Inject
+@Suppress("LongParameterList")
constructor(
+ savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
+ private val meshLogRepository: MeshLogRepository,
+ private val radioConfigRepository: RadioConfigRepository,
+ private val deviceHardwareRepository: DeviceHardwareRepository,
+ private val firmwareReleaseRepository: FirmwareReleaseRepository,
+ private val serviceRepository: ServiceRepository,
) : ViewModel() {
- init {
- nodeManagementActions.start(viewModelScope)
- nodeRequestActions.start(viewModelScope)
+ private val nodeIdFromRoute: Int? =
+ runCatching { savedStateHandle.toRoute().destNum }.getOrNull()
+
+ private val manualNodeId = MutableStateFlow(null)
+ private val activeNodeId =
+ combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> fromRoute ?: manual }
+ .distinctUntilChanged()
+
+ val uiState: StateFlow =
+ viewModelScope.launchMolecule(mode = RecompositionMode.Immediate) {
+ val nodeId by activeNodeId.collectAsState(null)
+
+ NodeDetailPresenter(
+ nodeId = nodeId,
+ nodeRepository = nodeRepository,
+ meshLogRepository = meshLogRepository,
+ radioConfigRepository = radioConfigRepository,
+ deviceHardwareRepository = deviceHardwareRepository,
+ firmwareReleaseRepository = firmwareReleaseRepository,
+ nodeRequestActions = nodeRequestActions,
+ )
+ }
+
+ val effects: SharedFlow = nodeRequestActions.effects
+
+ fun start(nodeId: Int) {
+ if (manualNodeId.value != nodeId) {
+ manualNodeId.value = nodeId
+ }
}
- val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo
-
- private val _lastTraceRouteTime = MutableStateFlow(null)
- val lastTraceRouteTime: StateFlow = _lastTraceRouteTime.asStateFlow()
-
- private val _lastRequestNeighborsTime = MutableStateFlow(null)
- val lastRequestNeighborsTime: StateFlow = _lastRequestNeighborsTime.asStateFlow()
-
fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) {
- is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num)
- is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node)
- is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node)
- is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node)
- is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num)
- is NodeMenuAction.RequestNeighborInfo -> {
- nodeRequestActions.requestNeighborInfo(action.node.num)
- _lastRequestNeighborsTime.value = System.currentTimeMillis()
- }
- is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num)
- is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type)
- is NodeMenuAction.TraceRoute -> {
- nodeRequestActions.requestTraceroute(action.node.num)
- _lastTraceRouteTime.value = System.currentTimeMillis()
- }
+ is NodeMenuAction.Remove -> nodeManagementActions.removeNode(viewModelScope, action.node.num)
+ is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(viewModelScope, action.node)
+ is NodeMenuAction.Mute -> nodeManagementActions.muteNode(viewModelScope, action.node)
+ is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(viewModelScope, action.node)
+ is NodeMenuAction.RequestUserInfo ->
+ nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.longName)
+ is NodeMenuAction.RequestNeighborInfo ->
+ nodeRequestActions.requestNeighborInfo(viewModelScope, action.node.num, action.node.user.longName)
+ is NodeMenuAction.RequestPosition ->
+ nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.longName)
+ is NodeMenuAction.RequestTelemetry ->
+ nodeRequestActions.requestTelemetry(
+ viewModelScope,
+ action.node.num,
+ action.node.user.longName,
+ action.type,
+ )
+ is NodeMenuAction.TraceRoute ->
+ nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.longName)
else -> {}
}
}
+ fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) }
+
fun setNodeNotes(nodeNum: Int, notes: String) {
- nodeManagementActions.setNodeNotes(nodeNum, notes)
+ nodeManagementActions.setNodeNotes(viewModelScope, nodeNum, notes)
+ }
+
+ fun getDirectMessageRoute(node: Node, ourNode: Node?): String {
+ val hasPKC = ourNode?.hasPKC == true
+ val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
+ return "${channel}${node.user.id}"
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
index 6d4674558..3a7df7d04 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
@@ -35,14 +35,8 @@ constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
) {
- private var scope: CoroutineScope? = null
-
- fun start(coroutineScope: CoroutineScope) {
- scope = coroutineScope
- }
-
- fun removeNode(nodeNum: Int) {
- scope?.launch(Dispatchers.IO) {
+ fun removeNode(scope: CoroutineScope, nodeNum: Int) {
+ scope.launch(Dispatchers.IO) {
Logger.i { "Removing node '$nodeNum'" }
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
@@ -54,8 +48,8 @@ constructor(
}
}
- fun ignoreNode(node: Node) {
- scope?.launch(Dispatchers.IO) {
+ fun ignoreNode(scope: CoroutineScope, node: Node) {
+ scope.launch(Dispatchers.IO) {
try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {
@@ -64,8 +58,8 @@ constructor(
}
}
- fun muteNode(node: Node) {
- scope?.launch(Dispatchers.IO) {
+ fun muteNode(scope: CoroutineScope, node: Node) {
+ scope.launch(Dispatchers.IO) {
try {
serviceRepository.onServiceAction(ServiceAction.Mute(node))
} catch (ex: RemoteException) {
@@ -74,8 +68,8 @@ constructor(
}
}
- fun favoriteNode(node: Node) {
- scope?.launch(Dispatchers.IO) {
+ fun favoriteNode(scope: CoroutineScope, node: Node) {
+ scope.launch(Dispatchers.IO) {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) {
@@ -84,8 +78,8 @@ constructor(
}
}
- fun setNodeNotes(nodeNum: Int, notes: String) {
- scope?.launch(Dispatchers.IO) {
+ fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) {
+ scope.launch(Dispatchers.IO) {
try {
nodeRepository.setNodeNotes(nodeNum, notes)
} catch (ex: java.io.IOException) {
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt
index dd3413753..f5874e4f5 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt
@@ -16,78 +16,141 @@
*/
package org.meshtastic.feature.node.detail
-import android.os.RemoteException
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.neighbor_info
+import org.meshtastic.core.strings.position
+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_host_metrics
+import org.meshtastic.core.strings.request_local_stats
+import org.meshtastic.core.strings.request_pax_metrics
+import org.meshtastic.core.strings.request_power_metrics
+import org.meshtastic.core.strings.requesting_from
+import org.meshtastic.core.strings.traceroute
+import org.meshtastic.core.strings.user_info
import javax.inject.Inject
import javax.inject.Singleton
+sealed class NodeRequestEffect {
+ data class ShowFeedback(val resource: StringResource, val args: List = emptyList()) : NodeRequestEffect()
+}
+
@Singleton
class NodeRequestActions @Inject constructor(private val serviceRepository: ServiceRepository) {
- private var scope: CoroutineScope? = null
- fun start(coroutineScope: CoroutineScope) {
- scope = coroutineScope
- }
+ private val _effects = MutableSharedFlow()
+ val effects: SharedFlow = _effects.asSharedFlow()
- fun requestUserInfo(destNum: Int) {
- scope?.launch(Dispatchers.IO) {
+ private val _lastTracerouteTimes = MutableStateFlow