mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-02-06 22:02:37 -05:00
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:
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 0–500.</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 & 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = {}) }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user