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

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
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model
enum class TelemetryType {
@@ -23,4 +22,6 @@ enum class TelemetryType {
AIR_QUALITY,
POWER,
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
* it under the terms of the GNU General Public License as published by
@@ -14,16 +14,25 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.meshtastic.core.strings
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
fun getString(stringResource: StringResource): String = runBlocking {
org.jetbrains.compose.resources.getString(stringResource)
}
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
org.jetbrains.compose.resources.getString(stringResource, *formatArgs)
val resolvedArgs =
formatArgs.map { arg ->
if (arg is StringResource) {
getString(arg)
} else {
arg
}
}
@Suppress("SpreadOperator")
org.jetbrains.compose.resources.getString(stringResource, *resolvedArgs.toTypedArray())
}

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_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="exchange_userinfo">Exchange user info</string>
<string name="userinfo">User Info</string>
<string name="meshtastic_new_nodes_notifications">New node notifications</string>
<string name="more_details">More details</string>
<string name="snr">SNR</string>
@@ -419,12 +419,12 @@
<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="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="position_log">Position Log</string>
<string name="position_log">Position</string>
<string name="last_position_update">Last position update</string>
<string name="env_metrics_log">Environment Metrics Log</string>
<string name="sig_metrics_log">Signal Metrics Log</string>
<string name="env_metrics_log">Environment Metrics</string>
<string name="sig_metrics_log">Signal Metrics</string>
<string name="administration">Administration</string>
<string name="remote_admin">Remote Administration</string>
<string name="bad">Bad</string>
@@ -434,7 +434,7 @@
<string name="share_to">Share to…</string>
<string name="signal">Signal</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>
<plurals name="traceroute_hops">
<item quantity="one">1 hop</item>
@@ -466,7 +466,7 @@
<string name="remove_favorite">Remove from favorites</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="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_2">Channel 2</string>
<string name="channel_3">Channel 3</string>
@@ -787,6 +787,8 @@
<string name="public_key_changed">Public Key Changed</string>
<string name="import_label">Import</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_telemetry">Request Telemetry</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_power_metrics">Power Metrics</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="actions">Actions</string>
<string name="firmware">Firmware</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="host_metrics_log">Host Metrics Log</string>
<string name="host_metrics_log">Host Metrics</string>
<string name="host">Host</string>
<string name="free_memory">Free Memory</string>
<string name="disk_free">Disk Free</string>
@@ -897,9 +901,9 @@
<string name="clear_selection">Clear selection</string>
<string name="message_input_label">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="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="ble_devices">BLE 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="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="loading">Loading</string>
<!-- Message Filter -->
<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.tooling.preview)
implementation(libs.androidx.navigation.common)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.molecule.runtime)
implementation(libs.kermit)
implementation(libs.coil)
implementation(libs.markdown.renderer.android)

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

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

View File

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

View File

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

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
* it under the terms of the GNU General Public License as published by
@@ -14,53 +14,104 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import android.content.ClipData
import androidx.annotation.DrawableRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable
fun InfoCard(icon: ImageVector, text: String, value: String, modifier: Modifier = Modifier, rotateIcon: Float = 0f) {
Card(modifier = modifier.padding(4.dp).width(100.dp).height(100.dp)) {
Box(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp), contentAlignment = Alignment.Center) {
Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
fun InfoCard(
text: String,
value: String,
icon: ImageVector? = null,
@DrawableRes iconRes: Int? = null,
modifier: Modifier = Modifier,
rotateIcon: Float = 0f,
) {
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val shape = MaterialTheme.shapes.medium
val copyLabel = stringResource(Res.string.copy)
Card(
modifier =
modifier
.defaultMinSize(minHeight = 48.dp)
.clip(shape)
.combinedClickable(
onLongClick = {
coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(text, value))) }
},
onLongClickLabel = copyLabel,
onClick = {},
role = Role.Button,
)
.semantics(mergeDescendants = true) { contentDescription = "$text: $value" },
shape = shape,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {
Row(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val iconModifier = Modifier.size(20.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) }
val iconTint = MaterialTheme.colorScheme.primary
if (icon != null) {
Icon(imageVector = icon, contentDescription = null, modifier = iconModifier, tint = iconTint)
}
if (iconRes != null) {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier.size(24.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) },
painter = painterResource(iconRes),
contentDescription = null,
modifier = iconModifier,
tint = iconTint,
)
}
Column {
Text(
textAlign = TextAlign.Center,
text = text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
value,
style = MaterialTheme.typography.labelLargeEmphasized,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -69,30 +120,7 @@ fun InfoCard(icon: ImageVector, text: String, value: String, modifier: Modifier
@Composable
internal fun DrawableInfoCard(@DrawableRes iconRes: Int, text: String, value: String, rotateIcon: Float = 0f) {
Card(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp)) {
Box(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp), contentAlignment = Alignment.Center) {
Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = text,
modifier = Modifier.size(24.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) },
)
Text(
textAlign = TextAlign.Center,
text = text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall,
)
Text(
text = value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
)
}
}
}
InfoCard(iconRes = iconRes, text = text, value = value, rotateIcon = rotateIcon)
}
inline fun Modifier.thenIf(precondition: Boolean, action: Modifier.() -> Modifier): Modifier =

View File

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

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
import androidx.compose.foundation.layout.Arrangement
import android.content.ClipData
import android.util.Base64
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Numbers
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.SignalCellularAlt
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Work
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.details
import org.meshtastic.core.strings.encryption_error
import org.meshtastic.core.strings.encryption_error_text
import org.meshtastic.core.strings.hops_away
import org.meshtastic.core.strings.node_id
import org.meshtastic.core.strings.node_number
import org.meshtastic.core.strings.node_sort_last_heard
import org.meshtastic.core.strings.public_key
import org.meshtastic.core.strings.role
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.short_name
import org.meshtastic.core.strings.snr
import org.meshtastic.core.strings.supported
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.user_id
import org.meshtastic.core.strings.via_mqtt
import org.meshtastic.core.ui.util.formatAgo
@Composable
fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) {
ElevatedCard(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
shape = MaterialTheme.shapes.extraLarge,
) {
SelectionContainer {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = stringResource(Res.string.details),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(20.dp))
if (node.mismatchKey) {
MismatchKeyWarning()
Spacer(Modifier.height(20.dp))
}
MainNodeDetails(node)
SectionCard(title = Res.string.details, modifier = modifier) {
Column {
if (node.mismatchKey) {
MismatchKeyWarning(Modifier.padding(horizontal = 16.dp))
Spacer(Modifier.height(16.dp))
}
MainNodeDetails(node)
}
}
}
@Composable
private fun MismatchKeyWarning() {
private fun MismatchKeyWarning(modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.KeyOff,
contentDescription = stringResource(Res.string.encryption_error),
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.width(12.dp))
@@ -125,68 +130,189 @@ private fun MismatchKeyWarning() {
@Composable
private fun MainNodeDetails(node: Node) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
InfoItem(
label = stringResource(Res.string.short_name),
value = node.user.shortName.ifEmpty { "???" },
icon = Icons.Default.Person,
modifier = Modifier.weight(1f),
)
InfoItem(
label = stringResource(Res.string.role),
value = node.user.role.name,
icon = Icons.Default.Work,
modifier = Modifier.weight(1f),
)
Column {
NameAndRoleRow(node)
SectionDivider()
NodeIdentificationRow(node)
SectionDivider()
HearsAndHopsRow(node)
SectionDivider()
UserAndUptimeRow(node)
SectionDivider()
SignalRow(node)
if (node.viaMqtt || node.manuallyVerified) {
SectionDivider()
MqttAndVerificationRow(node)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
InfoItem(
label = stringResource(Res.string.node_sort_last_heard),
value = formatAgo(node.lastHeard),
icon = Icons.Default.History,
modifier = Modifier.weight(1f),
)
InfoItem(
label = stringResource(Res.string.node_number),
value = node.num.toUInt().toString(),
icon = Icons.Default.Numbers,
modifier = Modifier.weight(1f),
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
InfoItem(
label = stringResource(Res.string.user_id),
value = node.user.id,
icon = Icons.Default.Person,
modifier = Modifier.weight(1f),
)
if (node.deviceMetrics.uptimeSeconds > 0) {
InfoItem(
label = stringResource(Res.string.uptime),
value = formatUptime(node.deviceMetrics.uptimeSeconds),
icon = Icons.Default.CheckCircle,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
val publicKey = node.publicKey ?: node.user.publicKey
if (!publicKey.isEmpty) {
SectionDivider()
PublicKeyItem(publicKey.toByteArray())
}
}
}
@Composable
private fun InfoItem(label: String, value: String, icon: ImageVector, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
private fun NameAndRoleRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
InfoItem(
label = stringResource(Res.string.short_name),
value = node.user.shortName.ifEmpty { "???" },
icon = Icons.Default.Person,
modifier = Modifier.weight(1f),
)
InfoItem(
label = stringResource(Res.string.role),
value = node.user.role.name,
icon = Icons.Default.Work,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun NodeIdentificationRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
InfoItem(
label = stringResource(Res.string.node_id),
value = DataPacket.nodeNumToDefaultId(node.num),
icon = Icons.Default.Numbers,
modifier = Modifier.weight(1f),
)
InfoItem(
label = stringResource(Res.string.node_number),
value = node.num.toUInt().toString(),
icon = Icons.Default.Numbers,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun HearsAndHopsRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
InfoItem(
label = stringResource(Res.string.node_sort_last_heard),
value = formatAgo(node.lastHeard),
icon = Icons.Default.History,
modifier = Modifier.weight(1f),
)
if (node.hopsAway >= 0) {
InfoItem(
label = stringResource(Res.string.hops_away),
value = node.hopsAway.toString(),
icon = Icons.Default.SignalCellularAlt,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
}
}
@Composable
private fun UserAndUptimeRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
InfoItem(
label = stringResource(Res.string.user_id),
value = node.user.id,
icon = Icons.Default.Person,
modifier = Modifier.weight(1f),
)
if (node.deviceMetrics.uptimeSeconds > 0) {
InfoItem(
label = stringResource(Res.string.uptime),
value = formatUptime(node.deviceMetrics.uptimeSeconds),
icon = Icons.Default.CheckCircle,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
}
}
@Composable
private fun SignalRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
if (node.snr != Float.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.snr),
value = "%.1f dB".format(node.snr),
icon = Icons.Default.SignalCellularAlt,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
if (node.rssi != Int.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.rssi),
value = "%d dBm".format(node.rssi),
icon = Icons.Default.SignalCellularAlt,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
}
}
@Composable
private fun MqttAndVerificationRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
if (node.viaMqtt) {
InfoItem(
label = stringResource(Res.string.via_mqtt),
value = "Yes",
icon = Icons.Default.Cloud,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
if (node.manuallyVerified) {
InfoItem(
label = stringResource(Res.string.supported),
value = "Verified",
icon = Icons.Default.Verified,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PublicKeyItem(publicKeyBytes: ByteArray) {
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.DEFAULT).trim()
val label = stringResource(Res.string.public_key)
val copyLabel = stringResource(Res.string.copy)
Column(
modifier =
Modifier.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.combinedClickable(
onLongClick = {
coroutineScope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, publicKeyBase64)))
}
},
onLongClickLabel = copyLabel,
onClick = {},
role = Role.Button,
)
.padding(horizontal = 20.dp, vertical = 8.dp)
.semantics(mergeDescendants = true) { contentDescription = "$label: $publicKeyBase64" },
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
@@ -196,14 +322,13 @@ private fun InfoItem(label: String, value: String, icon: ImageVector, modifier:
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
text = publicKeyBase64,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface,
)
}

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

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
* it under the terms of the GNU General Public License as published by
@@ -14,11 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <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.material.icons.Icons
@@ -65,15 +63,11 @@ internal fun PowerMetrics(node: Node) {
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.SpaceEvenly,
) {
metrics.chunked(2).forEach { rowMetrics ->
Column {
rowMetrics.forEach { metric ->
InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value)
}
}
metrics.forEach { metric ->
InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value)
}
}
}

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

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
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import android.Manifest
@@ -59,7 +58,6 @@ import org.meshtastic.feature.node.component.CompassSheetContent
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.MetricsSection
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.component.NotesSection
@@ -126,7 +124,7 @@ fun NodeDetailList(
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
val compassUiState by
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 =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
@@ -164,7 +162,9 @@ fun NodeDetailList(
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
node = node,
availableLogs = availableLogs,
onAction = onAction,
metricsState = metricsState,
)
PositionSection(
@@ -189,8 +189,6 @@ fun NodeDetailList(
DeviceDetailsSection(metricsState)
}
MetricsSection(node, metricsState, availableLogs, onAction)
NotesSection(node = node, onSaveNotes = onSaveNotes)
if (!metricsState.isManaged) {
@@ -231,7 +229,6 @@ private fun CompassSheetHost(
onRequestPosition: () -> Unit,
) {
if (showCompassSheet && compassViewModel != null) {
// Tie sensor lifecycle to the sheet so streams stop as soon as the sheet is dismissed.
DisposableEffect(Unit) { onDispose { compassViewModel.stop() } }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)

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
import android.Manifest
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.loading
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.AdministrationSection
import org.meshtastic.feature.node.component.CompassSheetContent
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.NodeDetailAction
@Suppress("LongMethod")
private sealed interface NodeDetailOverlay {
data object SharedContact : NodeDetailOverlay
data class FirmwareReleaseInfo(val release: FirmwareRelease) : NodeDetailOverlay
data object Compass : NodeDetailOverlay
}
@Composable
fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
metricsViewModel: MetricsViewModel = hiltViewModel(),
nodeDetailViewModel: NodeDetailViewModel = hiltViewModel(),
viewModel: NodeDetailViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
LaunchedEffect(nodeId) { metricsViewModel.setNodeId(nodeId) }
viewModel.start(nodeId)
val metricsState by metricsViewModel.state.collectAsStateWithLifecycle()
val environmentMetricsState by metricsViewModel.environmentState.collectAsStateWithLifecycle()
val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
val lastRequestNeighborsTime by nodeDetailViewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle()
val ourNode by nodeDetailViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val availableLogs by
remember(metricsState, environmentMetricsState) {
derivedStateOf {
buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentMetricsState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
if (effect is NodeRequestEffect.ShowFeedback) {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
}
}
}
val node = metricsState.node
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
NodeDetailScaffold(
modifier = modifier,
uiState = uiState,
snackbarHostState = snackbarHostState,
viewModel = viewModel,
navigateToMessages = navigateToMessages,
onNavigate = onNavigate,
onNavigateUp = onNavigateUp,
)
}
@Composable
@Suppress("LongParameterList")
private fun NodeDetailScaffold(
modifier: Modifier,
uiState: NodeDetailUiState,
snackbarHostState: SnackbarHostState,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
) {
var activeOverlay by remember { mutableStateOf<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(
modifier = modifier,
topBar = {
MainAppBar(
title = node?.user?.longName ?: "",
ourNode = ourNode,
ourNode = uiState.ourNode,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
@@ -92,74 +153,188 @@ fun NodeDetailScreen(
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
if (node != null) {
NodeDetailContent(
node = node,
ourNode = ourNode,
metricsState = metricsState,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
availableLogs = availableLogs,
onAction = { action ->
handleNodeAction(
action = action,
ourNode = ourNode,
node = node,
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
metricsViewModel = metricsViewModel,
nodeDetailViewModel = nodeDetailViewModel,
)
},
modifier = modifier.padding(paddingValues),
onSaveNotes = { num, notes -> nodeDetailViewModel.setNodeNotes(num, notes) },
NodeDetailContent(
uiState = uiState,
viewModel = viewModel,
scrollState = scrollState,
onAction = { action ->
when (action) {
is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
is NodeDetailAction.OpenCompass -> {
compassViewModel?.start(action.node, action.displayUnits)
activeOverlay = NodeDetailOverlay.Compass
}
else ->
handleNodeAction(
action = action,
uiState = uiState,
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
viewModel = viewModel,
)
}
},
onFirmwareSelect = { activeOverlay = NodeDetailOverlay.FirmwareReleaseInfo(it) },
modifier = Modifier.padding(paddingValues),
)
}
NodeDetailOverlays(activeOverlay, node, compassUiState, compassViewModel, { activeOverlay = null }) {
viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
}
}
@Composable
private fun NodeDetailContent(
uiState: NodeDetailUiState,
viewModel: NodeDetailViewModel,
scrollState: ScrollState,
onAction: (NodeDetailAction) -> Unit,
onFirmwareSelect: (FirmwareRelease) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedContent(
targetState = uiState.node != null,
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
label = "NodeDetailContent",
modifier = modifier,
) { isNodePresent ->
if (isNodePresent && uiState.node != null) {
NodeDetailList(
node = uiState.node,
ourNode = uiState.ourNode,
uiState = uiState,
scrollState = scrollState,
onAction = onAction,
onFirmwareSelect = onFirmwareSelect,
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
)
} else {
Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
val loadingDescription = stringResource(Res.string.loading)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.semantics { contentDescription = loadingDescription })
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NodeDetailOverlays(
overlay: NodeDetailOverlay?,
node: Node?,
compassUiState: CompassUiState,
compassViewModel: CompassViewModel?,
onDismiss: () -> Unit,
onRequestPosition: (Node) -> Unit,
) {
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
val locationSettingsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> }
when (overlay) {
is NodeDetailOverlay.SharedContact -> node?.let { SharedContactDialog(it, onDismiss) }
is NodeDetailOverlay.FirmwareReleaseInfo ->
NodeDetailBottomSheet(onDismiss) { FirmwareReleaseSheetContent(firmwareRelease = overlay.release) }
is NodeDetailOverlay.Compass -> {
DisposableEffect(Unit) { onDispose { compassViewModel?.stop() } }
NodeDetailBottomSheet(
onDismiss = {
compassViewModel?.stop()
onDismiss()
},
) {
CompassSheetContent(
uiState = compassUiState,
onRequestLocationPermission = {
val perms =
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
permissionLauncher.launch(perms)
},
onOpenLocationSettings = {
locationSettingsLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
},
onRequestPosition = { node?.let { onRequestPosition(it) } },
modifier = Modifier.padding(bottom = 24.dp),
)
}
}
null -> {}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
}
@Composable
private fun NodeDetailList(
node: Node,
ourNode: Node?,
uiState: NodeDetailUiState,
scrollState: ScrollState,
onAction: (NodeDetailAction) -> Unit,
onFirmwareSelect: (FirmwareRelease) -> Unit,
onSaveNotes: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize().verticalScroll(scrollState).padding(16.dp).focusable(),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
NodeDetailsSection(node)
DeviceActions(
node = node,
lastTracerouteTime = uiState.lastTracerouteTime,
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
availableLogs = uiState.availableLogs,
onAction = onAction,
metricsState = uiState.metricsState,
isLocal = uiState.metricsState.isLocal,
)
PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction)
if (uiState.metricsState.deviceHardware != null) DeviceDetailsSection(uiState.metricsState)
NotesSection(node = node, onSaveNotes = onSaveNotes)
if (!uiState.metricsState.isManaged) {
AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect)
}
}
}
private fun handleNodeAction(
action: NodeDetailAction,
ourNode: Node?,
node: Node,
uiState: NodeDetailUiState,
navigateToMessages: (String) -> Unit,
onNavigateUp: () -> Unit,
onNavigate: (Route) -> Unit,
metricsViewModel: MetricsViewModel,
nodeDetailViewModel: NodeDetailViewModel,
viewModel: NodeDetailViewModel,
) {
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> metricsViewModel.onServiceAction(action.action)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val hasPKC = ourNode?.hasPKC == true
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
navigateToMessages("${channel}${node.user.id}")
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
is NodeMenuAction.Remove -> {
nodeDetailViewModel.handleNodeMenuAction(menuAction)
viewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> nodeDetailViewModel.handleNodeMenuAction(menuAction)
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
is NodeDetailAction.ShareContact -> {
/* Handled in NodeDetailContent */
}
is NodeDetailAction.OpenCompass -> {
/* Handled in NodeDetailList */
}
else -> {}
}
}

View File

@@ -16,61 +16,128 @@
*/
package org.meshtastic.feature.node.detail
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import app.cash.molecule.RecompositionMode
import app.cash.molecule.launchMolecule
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import javax.inject.Inject
data class NodeDetailUiState(
val node: Node? = null,
val ourNode: Node? = null,
val metricsState: MetricsState = MetricsState.Empty,
val environmentState: EnvironmentMetricsState = EnvironmentMetricsState(),
val availableLogs: Set<LogsType> = emptySet(),
val lastTracerouteTime: Long? = null,
val lastRequestNeighborsTime: Long? = null,
)
@HiltViewModel
class NodeDetailViewModel
@Inject
@Suppress("LongParameterList")
constructor(
savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val serviceRepository: ServiceRepository,
) : ViewModel() {
init {
nodeManagementActions.start(viewModelScope)
nodeRequestActions.start(viewModelScope)
private val nodeIdFromRoute: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
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) {
when (action) {
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num)
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node)
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node)
is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num)
is NodeMenuAction.RequestNeighborInfo -> {
nodeRequestActions.requestNeighborInfo(action.node.num)
_lastRequestNeighborsTime.value = System.currentTimeMillis()
}
is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num)
is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type)
is NodeMenuAction.TraceRoute -> {
nodeRequestActions.requestTraceroute(action.node.num)
_lastTraceRouteTime.value = System.currentTimeMillis()
}
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(viewModelScope, action.node.num)
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(viewModelScope, action.node)
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(viewModelScope, action.node)
is NodeMenuAction.RequestUserInfo ->
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.longName)
is NodeMenuAction.RequestNeighborInfo ->
nodeRequestActions.requestNeighborInfo(viewModelScope, action.node.num, action.node.user.longName)
is NodeMenuAction.RequestPosition ->
nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.longName)
is NodeMenuAction.RequestTelemetry ->
nodeRequestActions.requestTelemetry(
viewModelScope,
action.node.num,
action.node.user.longName,
action.type,
)
is NodeMenuAction.TraceRoute ->
nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.longName)
else -> {}
}
}
fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) }
fun setNodeNotes(nodeNum: Int, notes: String) {
nodeManagementActions.setNodeNotes(nodeNum, notes)
nodeManagementActions.setNodeNotes(viewModelScope, nodeNum, notes)
}
fun getDirectMessageRoute(node: Node, ourNode: Node?): String {
val hasPKC = ourNode?.hasPKC == true
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
return "${channel}${node.user.id}"
}
}

View File

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

View File

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

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
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
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.rememberScrollState
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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -59,7 +64,9 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.air_util_definition
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.Green
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.MAX_PERCENT_VALUE
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
@@ -119,13 +127,26 @@ private val LEGEND_DATA =
),
)
@Suppress("LongMethod")
@Composable
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
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(
topBar = {
MainAppBar(
@@ -134,10 +155,20 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.DEVICE) }) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
)
}
}
},
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
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
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
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.items
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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -48,7 +53,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.strings.Res
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.OptionLabel
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.MS_PER_SEC
import org.meshtastic.feature.node.model.TimeFrame
@@ -79,10 +87,22 @@ import org.meshtastic.proto.copy
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit)
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> =
if (state.isFahrenheit) {
data.map { telemetry ->
@@ -110,10 +130,20 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) }) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
)
}
}
},
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
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
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
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.material.icons.Icons
import androidx.compose.material.icons.filled.DataArray
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
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.ui.component.MainAppBar
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.proto.TelemetryProtos
import java.text.DecimalFormat
@@ -71,6 +79,18 @@ import java.text.DecimalFormat
@Composable
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
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
@@ -82,10 +102,20 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o
showNodeChip = false,
canNavigateUp = true,
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 = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding),

View File

@@ -26,6 +26,7 @@ import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
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.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
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.fallback_node_name
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.node.detail.NodeRequestActions
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.ConfigProtos.Config
@@ -90,6 +95,7 @@ constructor(
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
) : ViewModel() {
private var destNum: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
@@ -195,6 +201,34 @@ constructor(
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
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 {
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
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
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.items
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.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,9 +59,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
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.OptionLabel
import org.meshtastic.core.ui.component.SlidingSelector
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.Portnums.PortNum
@@ -162,6 +171,19 @@ private fun PaxMetricsChart(
@Suppress("MagicNumber", "LongMethod")
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
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()
var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
// Only show logs that can be decoded as PaxcountProtos.Paxcount
@@ -204,10 +226,17 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
actions = {
if (!state.isLocal) {
IconButton(onClick = { metricsViewModel.requestTelemetry(TelemetryType.PAX) }) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
}
}
},
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// Time frame selector

View File

@@ -36,18 +36,24 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -59,6 +65,7 @@ import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.metersIn
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.theme.AppTheme
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.MeshProtos
@@ -162,9 +170,22 @@ private fun ActionButtons(
}
}
@Suppress("LongMethod")
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
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 =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
@@ -183,10 +204,17 @@ fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateU
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestPosition() }) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
}
}
},
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
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
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
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.rememberScrollState
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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -58,8 +63,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.channel_1
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.theme.GraphColors.InfantryBlue
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.MS_PER_SEC
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),
)
@Suppress("LongMethod")
@Composable
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
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(
topBar = {
MainAppBar(
@@ -135,10 +157,20 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.POWER) }) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
)
}
}
},
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
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
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
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.rememberScrollState
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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -56,7 +61,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.rssi
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.SlidingSelector
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.MS_PER_SEC
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),
)
@Suppress("LongMethod")
@Composable
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
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(
topBar = {
MainAppBar(
@@ -108,10 +129,20 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
showNodeChip = false,
canNavigateUp = true,
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 = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
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.Groups
import androidx.compose.material.icons.filled.PersonOff
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.StatusYellow
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.proto.MeshProtos
@@ -103,7 +110,7 @@ private data class TracerouteDialog(
)
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun TracerouteLogScreen(
modifier: Modifier = Modifier,
@@ -112,6 +119,18 @@ fun TracerouteLogScreen(
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> },
) {
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)" }
@@ -131,16 +150,27 @@ fun TracerouteLogScreen(
Scaffold(
topBar = {
val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState()
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
actions = {
if (!state.isLocal) {
CooldownIconButton(
onClick = { viewModel.requestTraceroute() },
cooldownTimestamp = lastTracerouteTime,
) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
}
}
},
onClickChip = {},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
LazyColumn(
modifier = modifier.fillMaxSize().padding(innerPadding),

View File

@@ -40,6 +40,7 @@ dd-sdk-android = "3.5.0"
detekt = "1.23.8"
devtools-ksp = "2.3.4"
markdownRenderer = "0.39.1"
molecule = "2.0.0"
osmdroid-android = "6.1.20"
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-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" }
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" }
protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" }
protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" }