feat(node): Refactor Node Detail screen and enhance user feedback (#4291)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-24 21:00:23 -06:00 committed by GitHub
parent 8eb349e794
commit 2cdfababe5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2014 additions and 1028 deletions

View file

@ -39,6 +39,7 @@ import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums import org.meshtastic.proto.Portnums
import org.meshtastic.proto.TelemetryProtos import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.paxcount
import org.meshtastic.proto.position import org.meshtastic.proto.position
import org.meshtastic.proto.telemetry import org.meshtastic.proto.telemetry
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@ -272,23 +273,39 @@ constructor(
fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
val telemetryRequest = telemetry {
when (type) { val portNum: Portnums.PortNum
TelemetryType.ENVIRONMENT -> val payloadBytes: ByteString
environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
TelemetryType.AIR_QUALITY -> airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance() if (type == TelemetryType.PAX) {
TelemetryType.POWER -> powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance() portNum = Portnums.PortNum.PAXCOUNTER_APP
TelemetryType.LOCAL_STATS -> localStats = TelemetryProtos.LocalStats.getDefaultInstance() payloadBytes = paxcount {}.toByteString()
TelemetryType.DEVICE -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance() } 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( packetHandler?.sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket( newMeshPacketTo(destNum).buildMeshPacket(
id = requestId, id = requestId,
channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
) { ) {
portnumValue = Portnums.PortNum.TELEMETRY_APP_VALUE portnumValue = portNum.number
payload = telemetryRequest.toByteString() payload = payloadBytes
wantResponse = true wantResponse = true
}, },
) )

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.core.model package org.meshtastic.core.model
enum class TelemetryType { enum class TelemetryType {
@ -23,4 +22,6 @@ enum class TelemetryType {
AIR_QUALITY, AIR_QUALITY,
POWER, POWER,
LOCAL_STATS, LOCAL_STATS,
HOST,
PAX,
} }

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.meshtastic.core.strings package com.meshtastic.core.strings
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
fun getString(stringResource: StringResource): String = runBlocking { fun getString(stringResource: StringResource): String = runBlocking {
org.jetbrains.compose.resources.getString(stringResource) org.jetbrains.compose.resources.getString(stringResource)
} }
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking { 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())
} }

View file

@ -411,7 +411,7 @@
<string name="encryption_pkc_text">Direct messages are using the new public key infrastructure for encryption.</string> <string name="encryption_pkc_text">Direct messages are using the new public key infrastructure for encryption.</string>
<string name="encryption_error">Public key mismatch</string> <string name="encryption_error">Public key mismatch</string>
<string name="encryption_error_text">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.</string> <string name="encryption_error_text">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.</string>
<string name="exchange_userinfo">Exchange user info</string> <string name="userinfo">User Info</string>
<string name="meshtastic_new_nodes_notifications">New node notifications</string> <string name="meshtastic_new_nodes_notifications">New node notifications</string>
<string name="more_details">More details</string> <string name="more_details">More details</string>
<string name="snr">SNR</string> <string name="snr">SNR</string>
@ -419,12 +419,12 @@
<string name="rssi">RSSI</string> <string name="rssi">RSSI</string>
<string name="rssi_definition">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.</string> <string name="rssi_definition">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.</string>
<string name="iaq_definition">(Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0500.</string> <string name="iaq_definition">(Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0500.</string>
<string name="device_metrics_log">Device Metrics Log</string> <string name="device_metrics_log">Device Metrics</string>
<string name="node_map">Node Map</string> <string name="node_map">Node Map</string>
<string name="position_log">Position Log</string> <string name="position_log">Position</string>
<string name="last_position_update">Last position update</string> <string name="last_position_update">Last position update</string>
<string name="env_metrics_log">Environment Metrics Log</string> <string name="env_metrics_log">Environment Metrics</string>
<string name="sig_metrics_log">Signal Metrics Log</string> <string name="sig_metrics_log">Signal Metrics</string>
<string name="administration">Administration</string> <string name="administration">Administration</string>
<string name="remote_admin">Remote Administration</string> <string name="remote_admin">Remote Administration</string>
<string name="bad">Bad</string> <string name="bad">Bad</string>
@ -434,7 +434,7 @@
<string name="share_to">Share to…</string> <string name="share_to">Share to…</string>
<string name="signal">Signal</string> <string name="signal">Signal</string>
<string name="signal_quality">Signal Quality</string> <string name="signal_quality">Signal Quality</string>
<string name="traceroute_log">Traceroute Log</string> <string name="traceroute_log">Traceroute</string>
<string name="traceroute_direct">Direct</string> <string name="traceroute_direct">Direct</string>
<plurals name="traceroute_hops"> <plurals name="traceroute_hops">
<item quantity="one">1 hop</item> <item quantity="one">1 hop</item>
@ -466,7 +466,7 @@
<string name="remove_favorite">Remove from favorites</string> <string name="remove_favorite">Remove from favorites</string>
<string name="favorite_add">Add '%1$s' as a favorite node?</string> <string name="favorite_add">Add '%1$s' as a favorite node?</string>
<string name="favorite_remove">Remove '%1$s' as a favorite node?</string> <string name="favorite_remove">Remove '%1$s' as a favorite node?</string>
<string name="power_metrics_log">Power Metrics Log</string> <string name="power_metrics_log">Power Metrics</string>
<string name="channel_1">Channel 1</string> <string name="channel_1">Channel 1</string>
<string name="channel_2">Channel 2</string> <string name="channel_2">Channel 2</string>
<string name="channel_3">Channel 3</string> <string name="channel_3">Channel 3</string>
@ -787,6 +787,8 @@
<string name="public_key_changed">Public Key Changed</string> <string name="public_key_changed">Public Key Changed</string>
<string name="import_label">Import</string> <string name="import_label">Import</string>
<string name="request">Request</string> <string name="request">Request</string>
<string name="requesting_from">Requesting %1$s from %2$s</string>
<string name="user_info">User info</string>
<string name="request_neighbor_info">NeighborInfo (2.7.15+)</string> <string name="request_neighbor_info">NeighborInfo (2.7.15+)</string>
<string name="request_telemetry">Request Telemetry</string> <string name="request_telemetry">Request Telemetry</string>
<string name="request_device_metrics">Device Metrics</string> <string name="request_device_metrics">Device Metrics</string>
@ -794,12 +796,14 @@
<string name="request_air_quality_metrics">Air-Quality Metrics</string> <string name="request_air_quality_metrics">Air-Quality Metrics</string>
<string name="request_power_metrics">Power Metrics</string> <string name="request_power_metrics">Power Metrics</string>
<string name="request_local_stats">Local Stats</string> <string name="request_local_stats">Local Stats</string>
<string name="request_host_metrics">Host Metrics</string>
<string name="request_pax_metrics">Pax Metrics</string>
<string name="request_metadata">Metadata</string> <string name="request_metadata">Metadata</string>
<string name="actions">Actions</string> <string name="actions">Actions</string>
<string name="firmware">Firmware</string> <string name="firmware">Firmware</string>
<string name="use_12h_format">Use 12h clock format</string> <string name="use_12h_format">Use 12h clock format</string>
<string name="display_time_in_12h_format">When enabled, the device will display the time in 12-hour format on screen.</string> <string name="display_time_in_12h_format">When enabled, the device will display the time in 12-hour format on screen.</string>
<string name="host_metrics_log">Host Metrics Log</string> <string name="host_metrics_log">Host Metrics</string>
<string name="host">Host</string> <string name="host">Host</string>
<string name="free_memory">Free Memory</string> <string name="free_memory">Free Memory</string>
<string name="disk_free">Disk Free</string> <string name="disk_free">Disk Free</string>
@ -897,9 +901,9 @@
<string name="clear_selection">Clear selection</string> <string name="clear_selection">Clear selection</string>
<string name="message_input_label">Message</string> <string name="message_input_label">Message</string>
<string name="type_a_message">Type a message</string> <string name="type_a_message">Type a message</string>
<string name="pax_metrics_log">PAX Metrics Log</string> <string name="pax_metrics_log">PAX Metrics</string>
<string name="pax">PAX</string> <string name="pax">PAX</string>
<string name="no_pax_metrics_logs">No PAX metrics logs available.</string> <string name="no_pax_metrics_logs">No PAX metrics available.</string>
<string name="wifi_devices">WiFi Devices</string> <string name="wifi_devices">WiFi Devices</string>
<string name="ble_devices">BLE Devices</string> <string name="ble_devices">BLE Devices</string>
<string name="bluetooth_paired_devices">Paired devices</string> <string name="bluetooth_paired_devices">Paired devices</string>
@ -1143,6 +1147,7 @@
<string name="add_channels_description">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.</string> <string name="add_channels_description">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.</string>
<string name="replace_channels_and_settings_title">Replace Channels &amp; Settings</string> <string name="replace_channels_and_settings_title">Replace Channels &amp; Settings</string>
<string name="replace_channels_and_settings_description">This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed.</string> <string name="replace_channels_and_settings_description">This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed.</string>
<string name="loading">Loading</string>
<!-- Message Filter --> <!-- Message Filter -->
<string name="filter_settings">Message Filter</string> <string name="filter_settings">Message Filter</string>

View file

@ -47,6 +47,8 @@ dependencies {
implementation(libs.androidx.compose.ui.text) implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.navigation.common) implementation(libs.androidx.navigation.common)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.molecule.runtime)
implementation(libs.kermit) implementation(libs.kermit)
implementation(libs.coil) implementation(libs.coil)
implementation(libs.markdown.renderer.android) implementation(libs.markdown.renderer.android)

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.component package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Column 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.Icons
import androidx.compose.material.icons.filled.ForkLeft import androidx.compose.material.icons.filled.ForkLeft
import androidx.compose.material.icons.filled.Icecream import androidx.compose.material.icons.filled.Icecream
import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Settings 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.asDeviceVersion 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.latest_stable_firmware
import org.meshtastic.core.strings.remote_admin import org.meshtastic.core.strings.remote_admin
import org.meshtastic.core.strings.request_metadata import org.meshtastic.core.strings.request_metadata
import org.meshtastic.core.ui.component.InsetDivider
import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
@ -68,14 +59,8 @@ fun AdministrationSection(
onFirmwareSelect: (FirmwareRelease) -> Unit, onFirmwareSelect: (FirmwareRelease) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ElevatedCard( SectionCard(title = Res.string.administration, modifier = modifier) {
modifier = modifier.fillMaxWidth(), Column {
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
shape = MaterialTheme.shapes.extraLarge,
) {
Column(modifier = Modifier.padding(vertical = 16.dp)) {
AdministrationHeader()
ListItem( ListItem(
text = stringResource(Res.string.request_metadata), text = stringResource(Res.string.request_metadata),
leadingIcon = Icons.Default.Memory, leadingIcon = Icons.Default.Memory,
@ -85,7 +70,7 @@ fun AdministrationSection(
}, },
) )
InsetDivider() SectionDivider()
ListItem( ListItem(
text = stringResource(Res.string.remote_admin), 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 @Composable
private fun FirmwareSection( private fun FirmwareSection(
metricsState: MetricsState, metricsState: MetricsState,
@ -122,20 +96,8 @@ private fun FirmwareSection(
firmwareVersion: String?, firmwareVersion: String?,
onFirmwareSelect: (FirmwareRelease) -> Unit, onFirmwareSelect: (FirmwareRelease) -> Unit,
) { ) {
ElevatedCard( SectionCard(title = Res.string.firmware) {
modifier = Modifier.fillMaxWidth(), Column {
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
shape = MaterialTheme.shapes.extraLarge,
) {
Column(modifier = Modifier.padding(vertical = 16.dp)) {
Text(
text = stringResource(Res.string.firmware),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
firmwareEdition?.let { edition -> firmwareEdition?.let { edition ->
val icon = val icon =
when (edition) { when (edition) {
@ -172,7 +134,7 @@ private fun FirmwareVersionItems(
val deviceVersion = DeviceVersion(version.substringBeforeLast(".")) val deviceVersion = DeviceVersion(version.substringBeforeLast("."))
val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha) val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha)
if (hasEdition) InsetDivider() if (hasEdition) SectionDivider()
ListItem( ListItem(
text = stringResource(Res.string.installed_firmware_version), text = stringResource(Res.string.installed_firmware_version),
@ -183,7 +145,7 @@ private fun FirmwareVersionItems(
trailingIcon = null, trailingIcon = null,
) )
InsetDivider() SectionDivider()
ListItem( ListItem(
text = stringResource(Res.string.latest_stable_firmware), text = stringResource(Res.string.latest_stable_firmware),
@ -195,7 +157,7 @@ private fun FirmwareVersionItems(
onClick = { onFirmwareSelect(latestStable) }, onClick = { onFirmwareSelect(latestStable) },
) )
InsetDivider() SectionDivider()
ListItem( ListItem(
text = stringResource(Res.string.latest_alpha_firmware), text = stringResource(Res.string.latest_alpha_firmware),

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 = {}) }
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

View file

@ -34,9 +34,6 @@ import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.QrCode2 import androidx.compose.material.icons.rounded.QrCode2
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults 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.Icon
import androidx.compose.material3.IconToggleButton import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
@ -51,7 +48,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.database.model.Node
@ -65,6 +61,8 @@ import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.share_contact import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchListItem 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.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@ -80,7 +78,9 @@ fun DeviceActions(
node: Node, node: Node,
lastTracerouteTime: Long?, lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?, lastRequestNeighborsTime: Long?,
availableLogs: Set<LogsType>,
onAction: (NodeDetailAction) -> Unit, onAction: (NodeDetailAction) -> Unit,
metricsState: MetricsState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isLocal: Boolean = false, isLocal: Boolean = false,
) { ) {
@ -99,34 +99,15 @@ fun DeviceActions(
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) }, onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
) )
ElevatedCard( Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
modifier = modifier.fillMaxWidth(), SectionCard(title = Res.string.actions) {
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
shape = MaterialTheme.shapes.extraLarge,
) {
Column(modifier = Modifier.padding(vertical = 12.dp)) {
ActionsHeader()
PrimaryActionsRow( PrimaryActionsRow(
node = node, node = node,
isLocal = isLocal, isLocal = isLocal,
onAction = onAction, onAction = onAction,
onFavoriteClick = { displayedDialog = DialogType.FAVORITE }, onFavoriteClick = { displayedDialog = DialogType.FAVORITE },
) )
SectionDivider(Modifier.padding(vertical = 8.dp))
if (!isLocal) {
ActionsDivider()
RemoteDeviceActions(
node = node,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
onAction = onAction,
)
}
ActionsDivider()
ManagementActions( ManagementActions(
node = node, node = node,
onIgnoreClick = { displayedDialog = DialogType.IGNORE }, onIgnoreClick = { displayedDialog = DialogType.IGNORE },
@ -134,28 +115,18 @@ fun DeviceActions(
onRemoveClick = { displayedDialog = DialogType.REMOVE }, 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 @Composable
private fun PrimaryActionsRow( private fun PrimaryActionsRow(
node: Node, node: Node,

View file

@ -30,11 +30,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Router import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.twotone.Verified 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.MaterialTheme.colorScheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest 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.hardware
import org.meshtastic.core.strings.supported import org.meshtastic.core.strings.supported
import org.meshtastic.core.strings.supported_by_community import org.meshtastic.core.strings.supported_by_community
import org.meshtastic.core.ui.component.InsetDivider
import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed 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 node = state.node ?: return
val deviceHardware = state.deviceHardware ?: return val deviceHardware = state.deviceHardware ?: return
ElevatedCard( SectionCard(title = Res.string.device, modifier = modifier) {
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(containerColor = colorScheme.surfaceContainerHigh),
shape = MaterialTheme.shapes.extraLarge,
) {
SelectionContainer { SelectionContainer {
Column(modifier = Modifier.padding(vertical = 16.dp)) { Column {
Text(
text = stringResource(Res.string.device),
style = MaterialTheme.typography.titleMedium,
color = colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
@ -90,7 +72,7 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
InsetDivider() SectionDivider()
val deviceText = val deviceText =
state.reportedTarget?.let { target -> "${deviceHardware.displayName} ($target)" } state.reportedTarget?.let { target -> "${deviceHardware.displayName} ($target)" }
?: deviceHardware.displayName ?: deviceHardware.displayName
@ -102,7 +84,7 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
trailingIcon = null, trailingIcon = null,
) )
InsetDivider() SectionDivider()
SupportStatusItem(deviceHardware.activelySupported) SupportStatusItem(deviceHardware.activelySupported)
} }

View file

@ -143,13 +143,6 @@ internal fun EnvironmentMetrics(
if (hasWeight()) { if (hasWeight()) {
add(VectorMetricInfo(Res.string.weight, "%.2f kg".format(weight), Icons.Default.Scale)) 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()) { if (hasTemperature() && hasRelativeHumidity()) {
val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity) val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity)
add( add(
@ -196,20 +189,21 @@ internal fun EnvironmentMetrics(
verticalArrangement = Arrangement.SpaceEvenly, verticalArrangement = Arrangement.SpaceEvenly,
) { ) {
vectorMetrics.forEach { metric -> vectorMetrics.forEach { metric ->
InfoCard( if (metric is DrawableMetricInfo) {
icon = metric.icon, DrawableInfoCard(
text = stringResource(metric.label), iconRes = metric.icon,
value = metric.value, text = stringResource(metric.label),
rotateIcon = metric.rotateIcon, value = metric.value,
) rotateIcon = metric.rotateIcon,
} )
drawableMetrics.forEach { metric -> } else if (metric is VectorMetricInfo) {
DrawableInfoCard( InfoCard(
iconRes = metric.icon, icon = metric.icon,
text = stringResource(metric.label), text = stringResource(metric.label),
value = metric.value, value = metric.value,
rotateIcon = metric.rotateIcon, rotateIcon = metric.rotateIcon,
) )
}
} }
} }
} }

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.component package org.meshtastic.feature.node.component
import android.content.ClipData
import androidx.annotation.DrawableRes 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.vector.ImageVector 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.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp 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 @Composable
fun InfoCard(icon: ImageVector, text: String, value: String, modifier: Modifier = Modifier, rotateIcon: Float = 0f) { fun InfoCard(
Card(modifier = modifier.padding(4.dp).width(100.dp).height(100.dp)) { text: String,
Box(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp), contentAlignment = Alignment.Center) { value: String,
Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { 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( Icon(
imageVector = icon, painter = painterResource(iconRes),
contentDescription = text, contentDescription = null,
modifier = Modifier.size(24.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) }, modifier = iconModifier,
tint = iconTint,
) )
}
Column {
Text( Text(
textAlign = TextAlign.Center, text,
text = text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( Text(
text = value, value,
maxLines = 1, style = MaterialTheme.typography.labelLargeEmphasized,
overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
) )
} }
} }
@ -69,30 +120,7 @@ fun InfoCard(icon: ImageVector, text: String, value: String, modifier: Modifier
@Composable @Composable
internal fun DrawableInfoCard(@DrawableRes iconRes: Int, text: String, value: String, rotateIcon: Float = 0f) { 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)) { InfoCard(iconRes = iconRes, text = text, value = value, rotateIcon = rotateIcon)
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,
)
}
}
}
} }
inline fun Modifier.thenIf(precondition: Boolean, action: Modifier.() -> Modifier): Modifier = inline fun Modifier.thenIf(precondition: Boolean, action: Modifier.() -> Modifier): Modifier =

View file

@ -25,10 +25,16 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext 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.compose.ui.tooling.preview.PreviewLightDark
import androidx.core.net.toUri import androidx.core.net.toUri
import co.touchlab.kermit.Logger 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.metersIn
import org.meshtastic.core.model.util.toString import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.elevation_suffix import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.strings.last_position_update import org.meshtastic.core.strings.last_position_update
import org.meshtastic.core.ui.component.BasicListItem import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.icon import org.meshtastic.core.ui.component.icon
import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import java.net.URLEncoder import java.net.URLEncoder
@ -64,7 +70,22 @@ fun LinkedCoordinatesItem(node: Node, displayUnits: DisplayUnits = DisplayUnits.
"${altitude.metersIn(displayUnits).toString(displayUnits)} $suffix" "${altitude.metersIn(displayUnits).toString(displayUnits)} $suffix"
} ?: "" } ?: ""
val copyLabel = stringResource(Res.string.copy)
BasicListItem( 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), text = stringResource(Res.string.last_position_update),
leadingIcon = Icons.Default.LocationOn, leadingIcon = Icons.Default.LocationOn,
supportingText = "$ago$coordinates$elevationText", supportingText = "$ago$coordinates$elevationText",
@ -77,8 +98,6 @@ fun LinkedCoordinatesItem(node: Node, displayUnits: DisplayUnits = DisplayUnits.
try { try {
if (intent.resolveActivity(context.packageManager) != null) { if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent) context.startActivity(intent)
} else {
coroutineScope.launch { context.showToast("No application available to open this location!") }
} }
} catch (ex: ActivityNotFoundException) { } catch (ex: ActivityNotFoundException) {
Logger.d { "Failed to open geo intent: $ex" } Logger.d { "Failed to open geo intent: $ex" }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<LogsType>,
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<LogsType>, 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,
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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),
)
}

View file

@ -16,93 +16,98 @@
*/ */
package org.meshtastic.feature.node.component 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle 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.History
import androidx.compose.material.icons.filled.KeyOff 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.Numbers
import androidx.compose.material.icons.filled.Person 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.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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.database.model.Node import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.details import org.meshtastic.core.strings.details
import org.meshtastic.core.strings.encryption_error import org.meshtastic.core.strings.encryption_error
import org.meshtastic.core.strings.encryption_error_text 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_number
import org.meshtastic.core.strings.node_sort_last_heard 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.role
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.short_name 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.uptime
import org.meshtastic.core.strings.user_id import org.meshtastic.core.strings.user_id
import org.meshtastic.core.strings.via_mqtt
import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.formatAgo
@Composable @Composable
fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) { fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) {
ElevatedCard( SectionCard(title = Res.string.details, modifier = modifier) {
modifier = modifier.fillMaxWidth(), Column {
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), if (node.mismatchKey) {
shape = MaterialTheme.shapes.extraLarge, MismatchKeyWarning(Modifier.padding(horizontal = 16.dp))
) { Spacer(Modifier.height(16.dp))
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)
} }
MainNodeDetails(node)
} }
} }
} }
@Composable @Composable
private fun MismatchKeyWarning() { private fun MismatchKeyWarning(modifier: Modifier = Modifier) {
Surface( Surface(
color = MaterialTheme.colorScheme.errorContainer, color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = Icons.Default.KeyOff, imageVector = Icons.Default.KeyOff,
contentDescription = stringResource(Res.string.encryption_error), contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer, tint = MaterialTheme.colorScheme.onErrorContainer,
) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
@ -125,68 +130,189 @@ private fun MismatchKeyWarning() {
@Composable @Composable
private fun MainNodeDetails(node: Node) { private fun MainNodeDetails(node: Node) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { NameAndRoleRow(node)
InfoItem( SectionDivider()
label = stringResource(Res.string.short_name), NodeIdentificationRow(node)
value = node.user.shortName.ifEmpty { "???" }, SectionDivider()
icon = Icons.Default.Person, HearsAndHopsRow(node)
modifier = Modifier.weight(1f), SectionDivider()
) UserAndUptimeRow(node)
InfoItem( SectionDivider()
label = stringResource(Res.string.role), SignalRow(node)
value = node.user.role.name, if (node.viaMqtt || node.manuallyVerified) {
icon = Icons.Default.Work, SectionDivider()
modifier = Modifier.weight(1f), MqttAndVerificationRow(node)
)
} }
val publicKey = node.publicKey ?: node.user.publicKey
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) if (!publicKey.isEmpty) {
SectionDivider()
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { PublicKeyItem(publicKey.toByteArray())
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))
}
} }
} }
} }
@Composable @Composable
private fun InfoItem(label: String, value: String, icon: ImageVector, modifier: Modifier = Modifier) { private fun NameAndRoleRow(node: Node) {
Column(modifier = modifier) { 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) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = icon, imageVector = Icons.Default.Lock,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
@ -196,14 +322,13 @@ private fun InfoItem(label: String, value: String, icon: ImageVector, modifier:
text = label, text = label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
) )
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
text = value, text = publicKeyBase64,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
} }

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.component package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement 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.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -46,7 +43,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource 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.feature.node.model.NodeDetailAction
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits 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 * Displays node position details, last update time, distance, and related actions like requesting position and
* accessing map/position logs. * accessing map/position logs.
@ -76,21 +75,9 @@ fun PositionSection(
) { ) {
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits) val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits)
val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0 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) { if (hasValidPosition) {
PositionMap(node, distance) PositionMap(node, distance)
LinkedCoordinatesItem(node, metricsState.displayUnits) LinkedCoordinatesItem(node, metricsState.displayUnits)
@ -168,7 +155,7 @@ private fun PositionActionButtons(
) { ) {
Button( Button(
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) }, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT),
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
@ -176,20 +163,30 @@ private fun PositionActionButtons(
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
), ),
) { ) {
Icon(Icons.Default.LocationOn, null, Modifier.size(20.dp)) Icon(Icons.Default.LocationOn, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(6.dp))
Text(text = stringResource(Res.string.exchange_position), maxLines = 1, overflow = TextOverflow.Ellipsis) Text(
text = stringResource(Res.string.exchange_position),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Visible,
)
} }
if (hasValidPosition) { if (hasValidPosition) {
FilledTonalButton( FilledTonalButton(
onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) }, onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(COMPASS_BUTTON_WEIGHT),
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
) { ) {
Icon(Icons.Default.Explore, null, Modifier.size(20.dp)) Icon(Icons.Default.Explore, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(6.dp))
Text(text = stringResource(Res.string.open_compass), maxLines = 1, overflow = TextOverflow.Ellipsis) Text(
text = stringResource(Res.string.open_compass),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
} }
} }

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.component package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -65,15 +63,11 @@ internal fun PowerMetrics(node: Node) {
} }
FlowRow( FlowRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.SpaceEvenly, verticalArrangement = Arrangement.SpaceEvenly,
) { ) {
metrics.chunked(2).forEach { rowMetrics -> metrics.forEach { metric ->
Column { InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value)
rowMetrics.forEach { metric ->
InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value)
}
}
} }
} }
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)) },
)
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<LogsType>,
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<TelemetricFeature> = 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)
}
}
}
}

View file

@ -30,50 +30,47 @@ constructor(
private val nodeManagementActions: NodeManagementActions, private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions, private val nodeRequestActions: NodeRequestActions,
) { ) {
private var scope: CoroutineScope? = null fun handleNodeMenuAction(scope: CoroutineScope, action: NodeMenuAction) {
fun start(coroutineScope: CoroutineScope) {
scope = coroutineScope
nodeManagementActions.start(coroutineScope)
nodeRequestActions.start(coroutineScope)
}
fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) { when (action) {
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num) is NodeMenuAction.Remove -> nodeManagementActions.removeNode(scope, action.node.num)
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node) is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(scope, action.node)
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node) is NodeMenuAction.Mute -> nodeManagementActions.muteNode(scope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node) is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(scope, action.node)
is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num) is NodeMenuAction.RequestUserInfo ->
is NodeMenuAction.RequestNeighborInfo -> nodeRequestActions.requestNeighborInfo(action.node.num) nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.longName)
is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num) is NodeMenuAction.RequestNeighborInfo ->
is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type) nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.longName)
is NodeMenuAction.TraceRoute -> nodeRequestActions.requestTraceroute(action.node.num) 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 -> {} else -> {}
} }
} }
fun setNodeNotes(nodeNum: Int, notes: String) { fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) {
nodeManagementActions.setNodeNotes(nodeNum, notes) nodeManagementActions.setNodeNotes(scope, nodeNum, notes)
} }
fun requestPosition(destNum: Int, position: Position) { fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) {
nodeRequestActions.requestPosition(destNum, position) nodeRequestActions.requestPosition(scope, destNum, longName, position)
} }
fun requestUserInfo(destNum: Int) { fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
nodeRequestActions.requestUserInfo(destNum) nodeRequestActions.requestUserInfo(scope, destNum, longName)
} }
fun requestNeighborInfo(destNum: Int) { fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
nodeRequestActions.requestNeighborInfo(destNum) nodeRequestActions.requestNeighborInfo(scope, destNum, longName)
} }
fun requestTelemetry(destNum: Int, type: TelemetryType) { fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
nodeRequestActions.requestTelemetry(destNum, type) nodeRequestActions.requestTelemetry(scope, destNum, longName, type)
} }
fun requestTraceroute(destNum: Int) { fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
nodeRequestActions.requestTraceroute(destNum) nodeRequestActions.requestTraceroute(scope, destNum, longName)
} }
} }

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.detail package org.meshtastic.feature.node.detail
import android.Manifest 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.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent 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.NodeDetailsSection
import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.component.NotesSection import org.meshtastic.feature.node.component.NotesSection
@ -126,7 +124,7 @@ fun NodeDetailList(
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>() val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
val compassUiState by val compassUiState by
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) } compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
var compassTargetNode by remember { mutableStateOf<Node?>(null) } // Cache target for sheet-side position requests var compassTargetNode by remember { mutableStateOf<Node?>(null) }
val permissionLauncher = val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> } rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
@ -164,7 +162,9 @@ fun NodeDetailList(
lastTracerouteTime = lastTracerouteTime, lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime, lastRequestNeighborsTime = lastRequestNeighborsTime,
node = node, node = node,
availableLogs = availableLogs,
onAction = onAction, onAction = onAction,
metricsState = metricsState,
) )
PositionSection( PositionSection(
@ -189,8 +189,6 @@ fun NodeDetailList(
DeviceDetailsSection(metricsState) DeviceDetailsSection(metricsState)
} }
MetricsSection(node, metricsState, availableLogs, onAction)
NotesSection(node = node, onSaveNotes = onSaveNotes) NotesSection(node = node, onSaveNotes = onSaveNotes)
if (!metricsState.isManaged) { if (!metricsState.isManaged) {
@ -231,7 +229,6 @@ private fun CompassSheetHost(
onRequestPosition: () -> Unit, onRequestPosition: () -> Unit,
) { ) {
if (showCompassSheet && compassViewModel != null) { 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() } } DisposableEffect(Unit) { onDispose { compassViewModel.stop() } }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<DeviceHardware?>(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<LogsType> = 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)
}

View file

@ -16,75 +16,136 @@
*/ */
package org.meshtastic.feature.node.detail 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding 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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold 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.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.navigation.Route 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.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.component.NodeMenuAction
import org.meshtastic.feature.node.metrics.MetricsViewModel import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.model.LogsType import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.NodeDetailAction 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 @Composable
fun NodeDetailScreen( fun NodeDetailScreen(
nodeId: Int, nodeId: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
metricsViewModel: MetricsViewModel = hiltViewModel(), viewModel: NodeDetailViewModel = hiltViewModel(),
nodeDetailViewModel: NodeDetailViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit = {}, navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {}, onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {}, onNavigateUp: () -> Unit = {},
) { ) {
LaunchedEffect(nodeId) { metricsViewModel.setNodeId(nodeId) } viewModel.start(nodeId)
val metricsState by metricsViewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() }
val environmentMetricsState by metricsViewModel.environmentState.collectAsStateWithLifecycle() LaunchedEffect(Unit) {
val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle() viewModel.effects.collect { effect ->
val lastRequestNeighborsTime by nodeDetailViewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle() if (effect is NodeRequestEffect.ShowFeedback) {
val ourNode by nodeDetailViewModel.ourNodeInfo.collectAsStateWithLifecycle() @Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
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 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<NodeDetailOverlay?>(null) }
val inspectionMode = LocalInspectionMode.current
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
val compassUiState by
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
val node = uiState.node
val scrollState = rememberScrollState()
@Suppress("ModifierNotUsedAtRoot")
Scaffold( Scaffold(
modifier = modifier,
topBar = { topBar = {
MainAppBar( MainAppBar(
title = node?.user?.longName ?: "", title = node?.user?.longName ?: "",
ourNode = ourNode, ourNode = uiState.ourNode,
showNodeChip = false, showNodeChip = false,
canNavigateUp = true, canNavigateUp = true,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
@ -92,74 +153,188 @@ fun NodeDetailScreen(
onClickChip = {}, onClickChip = {},
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues -> ) { paddingValues ->
if (node != null) { NodeDetailContent(
NodeDetailContent( uiState = uiState,
node = node, viewModel = viewModel,
ourNode = ourNode, scrollState = scrollState,
metricsState = metricsState, onAction = { action ->
lastTracerouteTime = lastTracerouteTime, when (action) {
lastRequestNeighborsTime = lastRequestNeighborsTime, is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
availableLogs = availableLogs, is NodeDetailAction.OpenCompass -> {
onAction = { action -> compassViewModel?.start(action.node, action.displayUnits)
handleNodeAction( activeOverlay = NodeDetailOverlay.Compass
action = action, }
ourNode = ourNode, else ->
node = node, handleNodeAction(
navigateToMessages = navigateToMessages, action = action,
onNavigateUp = onNavigateUp, uiState = uiState,
onNavigate = onNavigate, navigateToMessages = navigateToMessages,
metricsViewModel = metricsViewModel, onNavigateUp = onNavigateUp,
nodeDetailViewModel = nodeDetailViewModel, onNavigate = onNavigate,
) viewModel = viewModel,
}, )
modifier = modifier.padding(paddingValues), }
onSaveNotes = { num, notes -> nodeDetailViewModel.setNodeNotes(num, notes) }, },
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 { } else {
Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) { val loadingDescription = stringResource(Res.string.loading)
CircularProgressIndicator() 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( private fun handleNodeAction(
action: NodeDetailAction, action: NodeDetailAction,
ourNode: Node?, uiState: NodeDetailUiState,
node: Node,
navigateToMessages: (String) -> Unit, navigateToMessages: (String) -> Unit,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onNavigate: (Route) -> Unit, onNavigate: (Route) -> Unit,
metricsViewModel: MetricsViewModel, viewModel: NodeDetailViewModel,
nodeDetailViewModel: NodeDetailViewModel,
) { ) {
when (action) { when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route) is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> metricsViewModel.onServiceAction(action.action) is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> { is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) { when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> { is NodeMenuAction.DirectMessage -> {
val hasPKC = ourNode?.hasPKC == true val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel navigateToMessages(route)
navigateToMessages("${channel}${node.user.id}")
} }
is NodeMenuAction.Remove -> { is NodeMenuAction.Remove -> {
nodeDetailViewModel.handleNodeMenuAction(menuAction) viewModel.handleNodeMenuAction(menuAction)
onNavigateUp() onNavigateUp()
} }
else -> viewModel.handleNodeMenuAction(menuAction)
else -> nodeDetailViewModel.handleNodeMenuAction(menuAction)
} }
} }
else -> {}
is NodeDetailAction.ShareContact -> {
/* Handled in NodeDetailContent */
}
is NodeDetailAction.OpenCompass -> {
/* Handled in NodeDetailList */
}
} }
} }

View file

@ -16,61 +16,128 @@
*/ */
package org.meshtastic.feature.node.detail 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.ViewModel
import androidx.lifecycle.viewModelScope 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow 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.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node 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.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 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<LogsType> = emptySet(),
val lastTracerouteTime: Long? = null,
val lastRequestNeighborsTime: Long? = null,
)
@HiltViewModel @HiltViewModel
class NodeDetailViewModel class NodeDetailViewModel
@Inject @Inject
@Suppress("LongParameterList")
constructor( constructor(
savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository, private val nodeRepository: NodeRepository,
private val nodeManagementActions: NodeManagementActions, private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions, 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() { ) : ViewModel() {
init { private val nodeIdFromRoute: Int? =
nodeManagementActions.start(viewModelScope) runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
nodeRequestActions.start(viewModelScope)
private val manualNodeId = MutableStateFlow<Int?>(null)
private val activeNodeId =
combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> fromRoute ?: manual }
.distinctUntilChanged()
val uiState: StateFlow<NodeDetailUiState> =
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<NodeRequestEffect> = nodeRequestActions.effects
fun start(nodeId: Int) {
if (manualNodeId.value != nodeId) {
manualNodeId.value = nodeId
}
} }
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
val lastTraceRouteTime: StateFlow<Long?> = _lastTraceRouteTime.asStateFlow()
private val _lastRequestNeighborsTime = MutableStateFlow<Long?>(null)
val lastRequestNeighborsTime: StateFlow<Long?> = _lastRequestNeighborsTime.asStateFlow()
fun handleNodeMenuAction(action: NodeMenuAction) { fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) { when (action) {
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num) is NodeMenuAction.Remove -> nodeManagementActions.removeNode(viewModelScope, action.node.num)
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node) is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(viewModelScope, action.node)
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node) is NodeMenuAction.Mute -> nodeManagementActions.muteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node) is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(viewModelScope, action.node)
is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num) is NodeMenuAction.RequestUserInfo ->
is NodeMenuAction.RequestNeighborInfo -> { nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.longName)
nodeRequestActions.requestNeighborInfo(action.node.num) is NodeMenuAction.RequestNeighborInfo ->
_lastRequestNeighborsTime.value = System.currentTimeMillis() nodeRequestActions.requestNeighborInfo(viewModelScope, action.node.num, action.node.user.longName)
} is NodeMenuAction.RequestPosition ->
is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num) nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.longName)
is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type) is NodeMenuAction.RequestTelemetry ->
is NodeMenuAction.TraceRoute -> { nodeRequestActions.requestTelemetry(
nodeRequestActions.requestTraceroute(action.node.num) viewModelScope,
_lastTraceRouteTime.value = System.currentTimeMillis() action.node.num,
} action.node.user.longName,
action.type,
)
is NodeMenuAction.TraceRoute ->
nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.longName)
else -> {} else -> {}
} }
} }
fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) }
fun setNodeNotes(nodeNum: Int, notes: String) { 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}"
} }
} }

View file

@ -35,14 +35,8 @@ constructor(
private val nodeRepository: NodeRepository, private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository, private val serviceRepository: ServiceRepository,
) { ) {
private var scope: CoroutineScope? = null fun removeNode(scope: CoroutineScope, nodeNum: Int) {
scope.launch(Dispatchers.IO) {
fun start(coroutineScope: CoroutineScope) {
scope = coroutineScope
}
fun removeNode(nodeNum: Int) {
scope?.launch(Dispatchers.IO) {
Logger.i { "Removing node '$nodeNum'" } Logger.i { "Removing node '$nodeNum'" }
try { try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch val packetId = serviceRepository.meshService?.packetId ?: return@launch
@ -54,8 +48,8 @@ constructor(
} }
} }
fun ignoreNode(node: Node) { fun ignoreNode(scope: CoroutineScope, node: Node) {
scope?.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
serviceRepository.onServiceAction(ServiceAction.Ignore(node)) serviceRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) { } catch (ex: RemoteException) {
@ -64,8 +58,8 @@ constructor(
} }
} }
fun muteNode(node: Node) { fun muteNode(scope: CoroutineScope, node: Node) {
scope?.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
serviceRepository.onServiceAction(ServiceAction.Mute(node)) serviceRepository.onServiceAction(ServiceAction.Mute(node))
} catch (ex: RemoteException) { } catch (ex: RemoteException) {
@ -74,8 +68,8 @@ constructor(
} }
} }
fun favoriteNode(node: Node) { fun favoriteNode(scope: CoroutineScope, node: Node) {
scope?.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node)) serviceRepository.onServiceAction(ServiceAction.Favorite(node))
} catch (ex: RemoteException) { } catch (ex: RemoteException) {
@ -84,8 +78,8 @@ constructor(
} }
} }
fun setNodeNotes(nodeNum: Int, notes: String) { fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) {
scope?.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
nodeRepository.setNodeNotes(nodeNum, notes) nodeRepository.setNodeNotes(nodeNum, notes)
} catch (ex: java.io.IOException) { } catch (ex: java.io.IOException) {

View file

@ -16,78 +16,141 @@
*/ */
package org.meshtastic.feature.node.detail package org.meshtastic.feature.node.detail
import android.os.RemoteException
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.model.Position import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.service.ServiceRepository 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
sealed class NodeRequestEffect {
data class ShowFeedback(val resource: StringResource, val args: List<Any> = emptyList()) : NodeRequestEffect()
}
@Singleton @Singleton
class NodeRequestActions @Inject constructor(private val serviceRepository: ServiceRepository) { class NodeRequestActions @Inject constructor(private val serviceRepository: ServiceRepository) {
private var scope: CoroutineScope? = null
fun start(coroutineScope: CoroutineScope) { private val _effects = MutableSharedFlow<NodeRequestEffect>()
scope = coroutineScope val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()
}
fun requestUserInfo(destNum: Int) { private val _lastTracerouteTimes = MutableStateFlow<Map<Int, Long>>(emptyMap())
scope?.launch(Dispatchers.IO) { val lastTracerouteTimes: StateFlow<Map<Int, Long>> = _lastTracerouteTimes.asStateFlow()
private val _lastRequestNeighborTimes = MutableStateFlow<Map<Int, Long>>(emptyMap())
val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting UserInfo for '$destNum'" } Logger.i { "Requesting UserInfo for '$destNum'" }
try { try {
serviceRepository.meshService?.requestUserInfo(destNum) serviceRepository.meshService?.requestUserInfo(destNum)
} catch (ex: RemoteException) { _effects.emit(
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.user_info, longName)),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request NodeInfo error: ${ex.message}" } Logger.e { "Request NodeInfo error: ${ex.message}" }
} }
} }
} }
fun requestNeighborInfo(destNum: Int) { fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope?.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Logger.i { "Requesting NeighborInfo for '$destNum'" } Logger.i { "Requesting NeighborInfo for '$destNum'" }
try { try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.requestNeighborInfo(packetId, destNum) serviceRepository.meshService?.requestNeighborInfo(packetId, destNum)
} catch (ex: RemoteException) { _lastRequestNeighborTimes.update { it + (destNum to System.currentTimeMillis()) }
_effects.emit(
NodeRequestEffect.ShowFeedback(
Res.string.requesting_from,
listOf(Res.string.neighbor_info, longName),
),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request NeighborInfo error: ${ex.message}" } Logger.e { "Request NeighborInfo error: ${ex.message}" }
} }
} }
} }
fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) { fun requestPosition(
scope?.launch(Dispatchers.IO) { scope: CoroutineScope,
destNum: Int,
longName: String,
position: Position = Position(0.0, 0.0, 0),
) {
scope.launch(Dispatchers.IO) {
Logger.i { "Requesting position for '$destNum'" } Logger.i { "Requesting position for '$destNum'" }
try { try {
serviceRepository.meshService?.requestPosition(destNum, position) serviceRepository.meshService?.requestPosition(destNum, position)
} catch (ex: RemoteException) { _effects.emit(
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.position, longName)),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request position error: ${ex.message}" } Logger.e { "Request position error: ${ex.message}" }
} }
} }
} }
fun requestTelemetry(destNum: Int, type: TelemetryType) { fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
scope?.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Logger.i { "Requesting telemetry for '$destNum'" } Logger.i { "Requesting telemetry for '$destNum'" }
try { try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal) serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal)
} catch (ex: RemoteException) {
val typeRes =
when (type) {
TelemetryType.DEVICE -> Res.string.request_device_metrics
TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics
TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics
TelemetryType.POWER -> Res.string.request_power_metrics
TelemetryType.LOCAL_STATS -> Res.string.request_local_stats
TelemetryType.HOST -> Res.string.request_host_metrics
TelemetryType.PAX -> Res.string.request_pax_metrics
}
_effects.emit(NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(typeRes, longName)))
} catch (ex: android.os.RemoteException) {
Logger.e { "Request telemetry error: ${ex.message}" } Logger.e { "Request telemetry error: ${ex.message}" }
} }
} }
} }
fun requestTraceroute(destNum: Int) { fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
scope?.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Logger.i { "Requesting traceroute for '$destNum'" } Logger.i { "Requesting traceroute for '$destNum'" }
try { try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.requestTraceroute(packetId, destNum) serviceRepository.meshService?.requestTraceroute(packetId, destNum)
} catch (ex: RemoteException) { _lastTracerouteTimes.update { it + (destNum to System.currentTimeMillis()) }
_effects.emit(
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.traceroute, longName)),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request traceroute error: ${ex.message}" } Logger.e { "Request traceroute error: ${ex.message}" }
} }
} }

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.metrics package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@ -34,12 +33,18 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -59,7 +64,9 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.air_util_definition import org.meshtastic.core.strings.air_util_definition
import org.meshtastic.core.strings.air_utilization import org.meshtastic.core.strings.air_utilization
@ -75,6 +82,7 @@ import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.GraphColors.Cyan import org.meshtastic.core.ui.theme.GraphColors.Cyan
import org.meshtastic.core.ui.theme.GraphColors.Green import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.Magenta import org.meshtastic.core.ui.theme.GraphColors.Magenta
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MAX_PERCENT_VALUE import org.meshtastic.feature.node.metrics.CommonCharts.MAX_PERCENT_VALUE
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
@ -119,13 +127,26 @@ private val LEGEND_DATA =
), ),
) )
@Suppress("LongMethod")
@Composable @Composable
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var displayInfoDialog by remember { mutableStateOf(false) } var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState() val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val data = state.deviceMetricsFiltered(selectedTimeFrame) val data = state.deviceMetricsFiltered(selectedTimeFrame)
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
Scaffold( Scaffold(
topBar = { topBar = {
MainAppBar( MainAppBar(
@ -134,10 +155,20 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
showNodeChip = false, showNodeChip = false,
canNavigateUp = true, canNavigateUp = true,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
actions = {}, actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.DEVICE) }) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
)
}
}
},
onClickChip = {}, onClickChip = {},
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) { Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) { if (displayInfoDialog) {

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.metrics package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -29,12 +28,18 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -48,7 +53,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.current import org.meshtastic.core.strings.current
@ -68,6 +75,7 @@ import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.feature.node.model.TimeFrame
@ -79,10 +87,22 @@ import org.meshtastic.proto.copy
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle() val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val selectedTimeFrame by viewModel.timeFrame.collectAsState() val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit) val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit)
val data = graphData.metrics val data = graphData.metrics
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
val processedTelemetries: List<Telemetry> = val processedTelemetries: List<Telemetry> =
if (state.isFahrenheit) { if (state.isFahrenheit) {
data.map { telemetry -> data.map { telemetry ->
@ -110,10 +130,20 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
showNodeChip = false, showNodeChip = false,
canNavigateUp = true, canNavigateUp = true,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
actions = {}, actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) }) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
)
}
}
},
onClickChip = {}, onClickChip = {},
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) { Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) { if (displayInfoDialog) {

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.metrics package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@ -33,16 +32,22 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DataArray import androidx.compose.material.icons.filled.DataArray
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@ -53,7 +58,9 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.disk_free_indexed import org.meshtastic.core.strings.disk_free_indexed
@ -63,6 +70,7 @@ import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.user_string import org.meshtastic.core.strings.user_string
import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.proto.TelemetryProtos import org.meshtastic.proto.TelemetryProtos
import java.text.DecimalFormat import java.text.DecimalFormat
@ -71,6 +79,18 @@ import java.text.DecimalFormat
@Composable @Composable
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle() val state by metricsViewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
metricsViewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
val hostMetrics = state.hostMetrics val hostMetrics = state.hostMetrics
@ -82,10 +102,20 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o
showNodeChip = false, showNodeChip = false,
canNavigateUp = true, canNavigateUp = true,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
actions = {}, actions = {
if (!state.isLocal) {
IconButton(onClick = { metricsViewModel.requestTelemetry(TelemetryType.HOST) }) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Default.Refresh,
contentDescription = null,
)
}
}
},
onClickChip = {}, onClickChip = {},
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding), modifier = Modifier.fillMaxSize().padding(innerPadding),

View file

@ -26,6 +26,7 @@ import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -47,6 +48,7 @@ import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.TracerouteMapAvailability import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.NodesRoutes
@ -55,7 +57,10 @@ import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.ui.util.toPosition import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.ConfigProtos.Config import org.meshtastic.proto.ConfigProtos.Config
@ -90,6 +95,7 @@ constructor(
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val deviceHardwareRepository: DeviceHardwareRepository, private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository, private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
) : ViewModel() { ) : ViewModel() {
private var destNum: Int? = private var destNum: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull() runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
@ -195,6 +201,34 @@ constructor(
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
val timeFrame: StateFlow<TimeFrame> = _timeFrame val timeFrame: StateFlow<TimeFrame> = _timeFrame
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
val lastTraceRouteTime: StateFlow<Long?> =
nodeRequestActions.lastTracerouteTimes.map { it[destNum] }.stateInWhileSubscribed(null)
val lastRequestNeighborsTime: StateFlow<Long?> =
nodeRequestActions.lastRequestNeighborTimes.map { it[destNum] }.stateInWhileSubscribed(null)
fun requestUserInfo() {
destNum?.let { nodeRequestActions.requestUserInfo(viewModelScope, it, state.value.node?.user?.longName ?: "") }
}
fun requestPosition() {
destNum?.let { nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.longName ?: "") }
}
fun requestTelemetry(type: TelemetryType) {
destNum?.let {
nodeRequestActions.requestTelemetry(viewModelScope, it, state.value.node?.user?.longName ?: "", type)
}
}
fun requestTraceroute() {
destNum?.let {
nodeRequestActions.requestTraceroute(viewModelScope, it, state.value.node?.user?.longName ?: "")
}
}
init { init {
initializeFlows() initializeFlows()
} }

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.metrics package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@ -34,10 +33,17 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -53,9 +59,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.ble_devices import org.meshtastic.core.strings.ble_devices
@ -66,6 +74,7 @@ import org.meshtastic.core.strings.wifi_devices
import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.PaxcountProtos import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.Portnums.PortNum import org.meshtastic.proto.Portnums.PortNum
@ -162,6 +171,19 @@ private fun PaxMetricsChart(
@Suppress("MagicNumber", "LongMethod") @Suppress("MagicNumber", "LongMethod")
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by metricsViewModel.state.collectAsStateWithLifecycle() val state by metricsViewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
metricsViewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
val dateFormat = DateFormat.getDateTimeInstance() val dateFormat = DateFormat.getDateTimeInstance()
var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) } var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
// Only show logs that can be decoded as PaxcountProtos.Paxcount // Only show logs that can be decoded as PaxcountProtos.Paxcount
@ -204,10 +226,17 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
showNodeChip = false, showNodeChip = false,
canNavigateUp = true, canNavigateUp = true,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
actions = {}, actions = {
if (!state.isLocal) {
IconButton(onClick = { metricsViewModel.requestTelemetry(TelemetryType.PAX) }) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
}
}
},
onClickChip = {}, onClickChip = {},
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// Time frame selector // Time frame selector

View file

@ -36,18 +36,24 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -59,6 +65,7 @@ import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.metersIn import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString import org.meshtastic.core.model.util.toString
@ -75,6 +82,7 @@ import org.meshtastic.core.strings.timestamp
import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.formatPositionTime import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.MeshProtos import org.meshtastic.proto.MeshProtos
@ -162,9 +170,22 @@ private fun ActionButtons(
} }
} }
@Suppress("LongMethod")
@Composable @Composable
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
val exportPositionLauncher = val exportPositionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
@ -183,10 +204,17 @@ fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateU
showNodeChip = false, showNodeChip = false,
canNavigateUp = true, canNavigateUp = true,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
actions = {}, actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestPosition() }) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
}
}
},
onClickChip = {}, onClickChip = {},
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) { BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
val compactWidth = maxWidth < 600.dp val compactWidth = maxWidth < 600.dp

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.metrics package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@ -34,12 +33,18 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -58,8 +63,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.channel_1 import org.meshtastic.core.strings.channel_1
import org.meshtastic.core.strings.channel_2 import org.meshtastic.core.strings.channel_2
@ -71,6 +78,7 @@ import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.core.ui.theme.GraphColors.Red import org.meshtastic.core.ui.theme.GraphColors.Red
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.feature.node.metrics.GraphUtil.createPath import org.meshtastic.feature.node.metrics.GraphUtil.createPath
@ -121,12 +129,26 @@ private val LEGEND_DATA =
LegendData(nameRes = Res.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null), LegendData(nameRes = Res.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null),
) )
@Suppress("LongMethod")
@Composable @Composable
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val selectedTimeFrame by viewModel.timeFrame.collectAsState() val selectedTimeFrame by viewModel.timeFrame.collectAsState()
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
val data = state.powerMetricsFiltered(selectedTimeFrame) val data = state.powerMetricsFiltered(selectedTimeFrame)
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
Scaffold( Scaffold(
topBar = { topBar = {
MainAppBar( MainAppBar(
@ -135,10 +157,20 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
showNodeChip = false, showNodeChip = false,
canNavigateUp = true, canNavigateUp = true,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
actions = {}, actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.POWER) }) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
)
}
}
},
onClickChip = {}, onClickChip = {},
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) { Column(modifier = Modifier.padding(innerPadding)) {
PowerMetricsChart( PowerMetricsChart(

View file

@ -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 * 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 * 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 * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.meshtastic.feature.node.metrics package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@ -35,12 +34,18 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -56,7 +61,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.rssi import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.rssi_definition import org.meshtastic.core.strings.rssi_definition
@ -67,6 +74,7 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.OptionLabel import org.meshtastic.core.ui.component.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.core.ui.component.SnrAndRssi import org.meshtastic.core.ui.component.SnrAndRssi
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.feature.node.metrics.GraphUtil.plotPoint import org.meshtastic.feature.node.metrics.GraphUtil.plotPoint
@ -93,13 +101,26 @@ private val LEGEND_DATA =
LegendData(nameRes = Res.string.snr, color = Metric.SNR.color, environmentMetric = null), LegendData(nameRes = Res.string.snr, color = Metric.SNR.color, environmentMetric = null),
) )
@Suppress("LongMethod")
@Composable @Composable
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var displayInfoDialog by remember { mutableStateOf(false) } var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState() val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val data = state.signalMetricsFiltered(selectedTimeFrame) val data = state.signalMetricsFiltered(selectedTimeFrame)
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
Scaffold( Scaffold(
topBar = { topBar = {
MainAppBar( MainAppBar(
@ -108,10 +129,20 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
showNodeChip = false, showNodeChip = false,
canNavigateUp = true, canNavigateUp = true,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
actions = {}, actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) }) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
)
}
}
},
onClickChip = {}, onClickChip = {},
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) { Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) { if (displayInfoDialog) {

View file

@ -37,14 +37,19 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PersonOff import androidx.compose.material.icons.filled.PersonOff
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -92,6 +97,8 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.MeshProtos import org.meshtastic.proto.MeshProtos
@ -103,7 +110,7 @@ private data class TracerouteDialog(
) )
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable @Composable
fun TracerouteLogScreen( fun TracerouteLogScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -112,6 +119,18 @@ fun TracerouteLogScreen(
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> }, onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> },
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
}
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" } fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
@ -131,16 +150,27 @@ fun TracerouteLogScreen(
Scaffold( Scaffold(
topBar = { topBar = {
val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState()
MainAppBar( MainAppBar(
title = state.node?.user?.longName ?: "", title = state.node?.user?.longName ?: "",
ourNode = null, ourNode = null,
showNodeChip = false, showNodeChip = false,
canNavigateUp = true, canNavigateUp = true,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
actions = {}, actions = {
if (!state.isLocal) {
CooldownIconButton(
onClick = { viewModel.requestTraceroute() },
cooldownTimestamp = lastTracerouteTime,
) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
}
}
},
onClickChip = {}, onClickChip = {},
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize().padding(innerPadding), modifier = modifier.fillMaxSize().padding(innerPadding),

View file

@ -40,6 +40,7 @@ dd-sdk-android = "3.5.0"
detekt = "1.23.8" detekt = "1.23.8"
devtools-ksp = "2.3.4" devtools-ksp = "2.3.4"
markdownRenderer = "0.39.1" markdownRenderer = "0.39.1"
molecule = "2.0.0"
osmdroid-android = "6.1.20" osmdroid-android = "6.1.20"
protobuf = "4.33.4" protobuf = "4.33.4"
@ -109,6 +110,7 @@ location-services = { module = "com.google.android.gms:play-services-location",
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" } maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" } maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" }
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" } maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" } protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" }
protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" }