mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00: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:
parent
8eb349e794
commit
2cdfababe5
37 changed files with 2014 additions and 1028 deletions
|
|
@ -39,6 +39,7 @@ import org.meshtastic.proto.MeshProtos
|
||||||
import org.meshtastic.proto.MeshProtos.MeshPacket
|
import org.meshtastic.proto.MeshProtos.MeshPacket
|
||||||
import org.meshtastic.proto.Portnums
|
import org.meshtastic.proto.Portnums
|
||||||
import org.meshtastic.proto.TelemetryProtos
|
import org.meshtastic.proto.TelemetryProtos
|
||||||
|
import org.meshtastic.proto.paxcount
|
||||||
import org.meshtastic.proto.position
|
import org.meshtastic.proto.position
|
||||||
import org.meshtastic.proto.telemetry
|
import org.meshtastic.proto.telemetry
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
@ -272,23 +273,39 @@ constructor(
|
||||||
|
|
||||||
fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
|
fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
|
||||||
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
|
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
|
||||||
val telemetryRequest = telemetry {
|
|
||||||
when (type) {
|
val portNum: Portnums.PortNum
|
||||||
TelemetryType.ENVIRONMENT ->
|
val payloadBytes: ByteString
|
||||||
environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
|
|
||||||
TelemetryType.AIR_QUALITY -> airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance()
|
if (type == TelemetryType.PAX) {
|
||||||
TelemetryType.POWER -> powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance()
|
portNum = Portnums.PortNum.PAXCOUNTER_APP
|
||||||
TelemetryType.LOCAL_STATS -> localStats = TelemetryProtos.LocalStats.getDefaultInstance()
|
payloadBytes = paxcount {}.toByteString()
|
||||||
TelemetryType.DEVICE -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
|
} else {
|
||||||
}
|
portNum = Portnums.PortNum.TELEMETRY_APP
|
||||||
|
payloadBytes =
|
||||||
|
telemetry {
|
||||||
|
when (type) {
|
||||||
|
TelemetryType.ENVIRONMENT ->
|
||||||
|
environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
|
||||||
|
TelemetryType.AIR_QUALITY ->
|
||||||
|
airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance()
|
||||||
|
TelemetryType.POWER -> powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance()
|
||||||
|
TelemetryType.LOCAL_STATS -> localStats = TelemetryProtos.LocalStats.getDefaultInstance()
|
||||||
|
TelemetryType.DEVICE -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
|
||||||
|
TelemetryType.HOST -> hostMetrics = TelemetryProtos.HostMetrics.getDefaultInstance()
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toByteString()
|
||||||
}
|
}
|
||||||
|
|
||||||
packetHandler?.sendToRadio(
|
packetHandler?.sendToRadio(
|
||||||
newMeshPacketTo(destNum).buildMeshPacket(
|
newMeshPacketTo(destNum).buildMeshPacket(
|
||||||
id = requestId,
|
id = requestId,
|
||||||
channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
|
channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
|
||||||
) {
|
) {
|
||||||
portnumValue = Portnums.PortNum.TELEMETRY_APP_VALUE
|
portnumValue = portNum.number
|
||||||
payload = telemetryRequest.toByteString()
|
payload = payloadBytes
|
||||||
wantResponse = true
|
wantResponse = true
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.core.model
|
package org.meshtastic.core.model
|
||||||
|
|
||||||
enum class TelemetryType {
|
enum class TelemetryType {
|
||||||
|
|
@ -23,4 +22,6 @@ enum class TelemetryType {
|
||||||
AIR_QUALITY,
|
AIR_QUALITY,
|
||||||
POWER,
|
POWER,
|
||||||
LOCAL_STATS,
|
LOCAL_STATS,
|
||||||
|
HOST,
|
||||||
|
PAX,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,16 +14,25 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.meshtastic.core.strings
|
package com.meshtastic.core.strings
|
||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.jetbrains.compose.resources.StringResource
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
|
||||||
fun getString(stringResource: StringResource): String = runBlocking {
|
fun getString(stringResource: StringResource): String = runBlocking {
|
||||||
org.jetbrains.compose.resources.getString(stringResource)
|
org.jetbrains.compose.resources.getString(stringResource)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
|
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
|
||||||
org.jetbrains.compose.resources.getString(stringResource, *formatArgs)
|
val resolvedArgs =
|
||||||
|
formatArgs.map { arg ->
|
||||||
|
if (arg is StringResource) {
|
||||||
|
getString(arg)
|
||||||
|
} else {
|
||||||
|
arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
org.jetbrains.compose.resources.getString(stringResource, *resolvedArgs.toTypedArray())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -411,7 +411,7 @@
|
||||||
<string name="encryption_pkc_text">Direct messages are using the new public key infrastructure for encryption.</string>
|
<string name="encryption_pkc_text">Direct messages are using the new public key infrastructure for encryption.</string>
|
||||||
<string name="encryption_error">Public key mismatch</string>
|
<string name="encryption_error">Public key mismatch</string>
|
||||||
<string name="encryption_error_text">The public key does not match the recorded key. You may remove the node and let it exchange keys again, but this may indicate a more serious security problem. Contact the user through another trusted channel, to determine if the key change was due to a factory reset or other intentional action.</string>
|
<string name="encryption_error_text">The public key does not match the recorded key. You may remove the node and let it exchange keys again, but this may indicate a more serious security problem. Contact the user through another trusted channel, to determine if the key change was due to a factory reset or other intentional action.</string>
|
||||||
<string name="exchange_userinfo">Exchange user info</string>
|
<string name="userinfo">User Info</string>
|
||||||
<string name="meshtastic_new_nodes_notifications">New node notifications</string>
|
<string name="meshtastic_new_nodes_notifications">New node notifications</string>
|
||||||
<string name="more_details">More details</string>
|
<string name="more_details">More details</string>
|
||||||
<string name="snr">SNR</string>
|
<string name="snr">SNR</string>
|
||||||
|
|
@ -419,12 +419,12 @@
|
||||||
<string name="rssi">RSSI</string>
|
<string name="rssi">RSSI</string>
|
||||||
<string name="rssi_definition">Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection.</string>
|
<string name="rssi_definition">Received Signal Strength Indicator, a measurement used to determine the power level being received by the antenna. A higher RSSI value generally indicates a stronger and more stable connection.</string>
|
||||||
<string name="iaq_definition">(Indoor Air Quality) relative scale IAQ value as measured by Bosch BME680. Value Range 0–500.</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="node_map">Node Map</string>
|
||||||
<string name="position_log">Position Log</string>
|
<string name="position_log">Position</string>
|
||||||
<string name="last_position_update">Last position update</string>
|
<string name="last_position_update">Last position update</string>
|
||||||
<string name="env_metrics_log">Environment Metrics Log</string>
|
<string name="env_metrics_log">Environment Metrics</string>
|
||||||
<string name="sig_metrics_log">Signal Metrics Log</string>
|
<string name="sig_metrics_log">Signal Metrics</string>
|
||||||
<string name="administration">Administration</string>
|
<string name="administration">Administration</string>
|
||||||
<string name="remote_admin">Remote Administration</string>
|
<string name="remote_admin">Remote Administration</string>
|
||||||
<string name="bad">Bad</string>
|
<string name="bad">Bad</string>
|
||||||
|
|
@ -434,7 +434,7 @@
|
||||||
<string name="share_to">Share to…</string>
|
<string name="share_to">Share to…</string>
|
||||||
<string name="signal">Signal</string>
|
<string name="signal">Signal</string>
|
||||||
<string name="signal_quality">Signal Quality</string>
|
<string name="signal_quality">Signal Quality</string>
|
||||||
<string name="traceroute_log">Traceroute Log</string>
|
<string name="traceroute_log">Traceroute</string>
|
||||||
<string name="traceroute_direct">Direct</string>
|
<string name="traceroute_direct">Direct</string>
|
||||||
<plurals name="traceroute_hops">
|
<plurals name="traceroute_hops">
|
||||||
<item quantity="one">1 hop</item>
|
<item quantity="one">1 hop</item>
|
||||||
|
|
@ -466,7 +466,7 @@
|
||||||
<string name="remove_favorite">Remove from favorites</string>
|
<string name="remove_favorite">Remove from favorites</string>
|
||||||
<string name="favorite_add">Add '%1$s' as a favorite node?</string>
|
<string name="favorite_add">Add '%1$s' as a favorite node?</string>
|
||||||
<string name="favorite_remove">Remove '%1$s' as a favorite node?</string>
|
<string name="favorite_remove">Remove '%1$s' as a favorite node?</string>
|
||||||
<string name="power_metrics_log">Power Metrics Log</string>
|
<string name="power_metrics_log">Power Metrics</string>
|
||||||
<string name="channel_1">Channel 1</string>
|
<string name="channel_1">Channel 1</string>
|
||||||
<string name="channel_2">Channel 2</string>
|
<string name="channel_2">Channel 2</string>
|
||||||
<string name="channel_3">Channel 3</string>
|
<string name="channel_3">Channel 3</string>
|
||||||
|
|
@ -787,6 +787,8 @@
|
||||||
<string name="public_key_changed">Public Key Changed</string>
|
<string name="public_key_changed">Public Key Changed</string>
|
||||||
<string name="import_label">Import</string>
|
<string name="import_label">Import</string>
|
||||||
<string name="request">Request</string>
|
<string name="request">Request</string>
|
||||||
|
<string name="requesting_from">Requesting %1$s from %2$s</string>
|
||||||
|
<string name="user_info">User info</string>
|
||||||
<string name="request_neighbor_info">NeighborInfo (2.7.15+)</string>
|
<string name="request_neighbor_info">NeighborInfo (2.7.15+)</string>
|
||||||
<string name="request_telemetry">Request Telemetry</string>
|
<string name="request_telemetry">Request Telemetry</string>
|
||||||
<string name="request_device_metrics">Device Metrics</string>
|
<string name="request_device_metrics">Device Metrics</string>
|
||||||
|
|
@ -794,12 +796,14 @@
|
||||||
<string name="request_air_quality_metrics">Air-Quality Metrics</string>
|
<string name="request_air_quality_metrics">Air-Quality Metrics</string>
|
||||||
<string name="request_power_metrics">Power Metrics</string>
|
<string name="request_power_metrics">Power Metrics</string>
|
||||||
<string name="request_local_stats">Local Stats</string>
|
<string name="request_local_stats">Local Stats</string>
|
||||||
|
<string name="request_host_metrics">Host Metrics</string>
|
||||||
|
<string name="request_pax_metrics">Pax Metrics</string>
|
||||||
<string name="request_metadata">Metadata</string>
|
<string name="request_metadata">Metadata</string>
|
||||||
<string name="actions">Actions</string>
|
<string name="actions">Actions</string>
|
||||||
<string name="firmware">Firmware</string>
|
<string name="firmware">Firmware</string>
|
||||||
<string name="use_12h_format">Use 12h clock format</string>
|
<string name="use_12h_format">Use 12h clock format</string>
|
||||||
<string name="display_time_in_12h_format">When enabled, the device will display the time in 12-hour format on screen.</string>
|
<string name="display_time_in_12h_format">When enabled, the device will display the time in 12-hour format on screen.</string>
|
||||||
<string name="host_metrics_log">Host Metrics Log</string>
|
<string name="host_metrics_log">Host Metrics</string>
|
||||||
<string name="host">Host</string>
|
<string name="host">Host</string>
|
||||||
<string name="free_memory">Free Memory</string>
|
<string name="free_memory">Free Memory</string>
|
||||||
<string name="disk_free">Disk Free</string>
|
<string name="disk_free">Disk Free</string>
|
||||||
|
|
@ -897,9 +901,9 @@
|
||||||
<string name="clear_selection">Clear selection</string>
|
<string name="clear_selection">Clear selection</string>
|
||||||
<string name="message_input_label">Message</string>
|
<string name="message_input_label">Message</string>
|
||||||
<string name="type_a_message">Type a message</string>
|
<string name="type_a_message">Type a message</string>
|
||||||
<string name="pax_metrics_log">PAX Metrics Log</string>
|
<string name="pax_metrics_log">PAX Metrics</string>
|
||||||
<string name="pax">PAX</string>
|
<string name="pax">PAX</string>
|
||||||
<string name="no_pax_metrics_logs">No PAX metrics logs available.</string>
|
<string name="no_pax_metrics_logs">No PAX metrics available.</string>
|
||||||
<string name="wifi_devices">WiFi Devices</string>
|
<string name="wifi_devices">WiFi Devices</string>
|
||||||
<string name="ble_devices">BLE Devices</string>
|
<string name="ble_devices">BLE Devices</string>
|
||||||
<string name="bluetooth_paired_devices">Paired devices</string>
|
<string name="bluetooth_paired_devices">Paired devices</string>
|
||||||
|
|
@ -1143,6 +1147,7 @@
|
||||||
<string name="add_channels_description">The following channels were found in the QR code. Select the once you would like to add to your device. Existing channels will be preserved.</string>
|
<string name="add_channels_description">The following channels were found in the QR code. Select the once you would like to add to your device. Existing channels will be preserved.</string>
|
||||||
<string name="replace_channels_and_settings_title">Replace Channels & Settings</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="replace_channels_and_settings_description">This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed.</string>
|
||||||
|
<string name="loading">Loading</string>
|
||||||
|
|
||||||
<!-- Message Filter -->
|
<!-- Message Filter -->
|
||||||
<string name="filter_settings">Message Filter</string>
|
<string name="filter_settings">Message Filter</string>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ dependencies {
|
||||||
implementation(libs.androidx.compose.ui.text)
|
implementation(libs.androidx.compose.ui.text)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.navigation.common)
|
implementation(libs.androidx.navigation.common)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.molecule.runtime)
|
||||||
implementation(libs.kermit)
|
implementation(libs.kermit)
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
implementation(libs.markdown.renderer.android)
|
implementation(libs.markdown.renderer.android)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,26 +14,18 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.component
|
package org.meshtastic.feature.node.component
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ForkLeft
|
import androidx.compose.material.icons.filled.ForkLeft
|
||||||
import androidx.compose.material.icons.filled.Icecream
|
import androidx.compose.material.icons.filled.Icecream
|
||||||
import androidx.compose.material.icons.filled.Memory
|
import androidx.compose.material.icons.filled.Memory
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||||
import org.meshtastic.core.database.entity.asDeviceVersion
|
import org.meshtastic.core.database.entity.asDeviceVersion
|
||||||
|
|
@ -50,7 +42,6 @@ import org.meshtastic.core.strings.latest_alpha_firmware
|
||||||
import org.meshtastic.core.strings.latest_stable_firmware
|
import org.meshtastic.core.strings.latest_stable_firmware
|
||||||
import org.meshtastic.core.strings.remote_admin
|
import org.meshtastic.core.strings.remote_admin
|
||||||
import org.meshtastic.core.strings.request_metadata
|
import org.meshtastic.core.strings.request_metadata
|
||||||
import org.meshtastic.core.ui.component.InsetDivider
|
|
||||||
import org.meshtastic.core.ui.component.ListItem
|
import org.meshtastic.core.ui.component.ListItem
|
||||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||||
|
|
@ -68,14 +59,8 @@ fun AdministrationSection(
|
||||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
ElevatedCard(
|
SectionCard(title = Res.string.administration, modifier = modifier) {
|
||||||
modifier = modifier.fillMaxWidth(),
|
Column {
|
||||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 16.dp)) {
|
|
||||||
AdministrationHeader()
|
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
text = stringResource(Res.string.request_metadata),
|
text = stringResource(Res.string.request_metadata),
|
||||||
leadingIcon = Icons.Default.Memory,
|
leadingIcon = Icons.Default.Memory,
|
||||||
|
|
@ -85,7 +70,7 @@ fun AdministrationSection(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
InsetDivider()
|
SectionDivider()
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
text = stringResource(Res.string.remote_admin),
|
text = stringResource(Res.string.remote_admin),
|
||||||
|
|
@ -104,17 +89,6 @@ fun AdministrationSection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AdministrationHeader() {
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.administration),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FirmwareSection(
|
private fun FirmwareSection(
|
||||||
metricsState: MetricsState,
|
metricsState: MetricsState,
|
||||||
|
|
@ -122,20 +96,8 @@ private fun FirmwareSection(
|
||||||
firmwareVersion: String?,
|
firmwareVersion: String?,
|
||||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||||
) {
|
) {
|
||||||
ElevatedCard(
|
SectionCard(title = Res.string.firmware) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Column {
|
||||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.firmware),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
firmwareEdition?.let { edition ->
|
firmwareEdition?.let { edition ->
|
||||||
val icon =
|
val icon =
|
||||||
when (edition) {
|
when (edition) {
|
||||||
|
|
@ -172,7 +134,7 @@ private fun FirmwareVersionItems(
|
||||||
val deviceVersion = DeviceVersion(version.substringBeforeLast("."))
|
val deviceVersion = DeviceVersion(version.substringBeforeLast("."))
|
||||||
val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha)
|
val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha)
|
||||||
|
|
||||||
if (hasEdition) InsetDivider()
|
if (hasEdition) SectionDivider()
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
text = stringResource(Res.string.installed_firmware_version),
|
text = stringResource(Res.string.installed_firmware_version),
|
||||||
|
|
@ -183,7 +145,7 @@ private fun FirmwareVersionItems(
|
||||||
trailingIcon = null,
|
trailingIcon = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
InsetDivider()
|
SectionDivider()
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
text = stringResource(Res.string.latest_stable_firmware),
|
text = stringResource(Res.string.latest_stable_firmware),
|
||||||
|
|
@ -195,7 +157,7 @@ private fun FirmwareVersionItems(
|
||||||
onClick = { onFirmwareSelect(latestStable) },
|
onClick = { onFirmwareSelect(latestStable) },
|
||||||
)
|
)
|
||||||
|
|
||||||
InsetDivider()
|
SectionDivider()
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
text = stringResource(Res.string.latest_alpha_firmware),
|
text = stringResource(Res.string.latest_alpha_firmware),
|
||||||
|
|
|
||||||
|
|
@ -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.material.icons.rounded.QrCode2
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconToggleButton
|
import androidx.compose.material3.IconToggleButton
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
|
@ -51,7 +48,6 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.meshtastic.core.database.model.Node
|
import org.meshtastic.core.database.model.Node
|
||||||
|
|
@ -65,6 +61,8 @@ import org.meshtastic.core.strings.remove
|
||||||
import org.meshtastic.core.strings.share_contact
|
import org.meshtastic.core.strings.share_contact
|
||||||
import org.meshtastic.core.ui.component.ListItem
|
import org.meshtastic.core.ui.component.ListItem
|
||||||
import org.meshtastic.core.ui.component.SwitchListItem
|
import org.meshtastic.core.ui.component.SwitchListItem
|
||||||
|
import org.meshtastic.feature.node.model.LogsType
|
||||||
|
import org.meshtastic.feature.node.model.MetricsState
|
||||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||||
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
|
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
|
||||||
|
|
||||||
|
|
@ -80,7 +78,9 @@ fun DeviceActions(
|
||||||
node: Node,
|
node: Node,
|
||||||
lastTracerouteTime: Long?,
|
lastTracerouteTime: Long?,
|
||||||
lastRequestNeighborsTime: Long?,
|
lastRequestNeighborsTime: Long?,
|
||||||
|
availableLogs: Set<LogsType>,
|
||||||
onAction: (NodeDetailAction) -> Unit,
|
onAction: (NodeDetailAction) -> Unit,
|
||||||
|
metricsState: MetricsState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isLocal: Boolean = false,
|
isLocal: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
|
@ -99,34 +99,15 @@ fun DeviceActions(
|
||||||
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
|
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
|
||||||
)
|
)
|
||||||
|
|
||||||
ElevatedCard(
|
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
modifier = modifier.fillMaxWidth(),
|
SectionCard(title = Res.string.actions) {
|
||||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 12.dp)) {
|
|
||||||
ActionsHeader()
|
|
||||||
|
|
||||||
PrimaryActionsRow(
|
PrimaryActionsRow(
|
||||||
node = node,
|
node = node,
|
||||||
isLocal = isLocal,
|
isLocal = isLocal,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
onFavoriteClick = { displayedDialog = DialogType.FAVORITE },
|
onFavoriteClick = { displayedDialog = DialogType.FAVORITE },
|
||||||
)
|
)
|
||||||
|
SectionDivider(Modifier.padding(vertical = 8.dp))
|
||||||
if (!isLocal) {
|
|
||||||
ActionsDivider()
|
|
||||||
|
|
||||||
RemoteDeviceActions(
|
|
||||||
node = node,
|
|
||||||
lastTracerouteTime = lastTracerouteTime,
|
|
||||||
lastRequestNeighborsTime = lastRequestNeighborsTime,
|
|
||||||
onAction = onAction,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ActionsDivider()
|
|
||||||
|
|
||||||
ManagementActions(
|
ManagementActions(
|
||||||
node = node,
|
node = node,
|
||||||
onIgnoreClick = { displayedDialog = DialogType.IGNORE },
|
onIgnoreClick = { displayedDialog = DialogType.IGNORE },
|
||||||
|
|
@ -134,28 +115,18 @@ fun DeviceActions(
|
||||||
onRemoveClick = { displayedDialog = DialogType.REMOVE },
|
onRemoveClick = { displayedDialog = DialogType.REMOVE },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TelemetricActionsSection(
|
||||||
|
node = node,
|
||||||
|
availableLogs = availableLogs,
|
||||||
|
lastTracerouteTime = lastTracerouteTime,
|
||||||
|
lastRequestNeighborsTime = lastRequestNeighborsTime,
|
||||||
|
metricsState = metricsState,
|
||||||
|
onAction = onAction,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ActionsHeader() {
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.actions),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ActionsDivider() {
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PrimaryActionsRow(
|
private fun PrimaryActionsRow(
|
||||||
node: Node,
|
node: Node,
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Router
|
import androidx.compose.material.icons.filled.Router
|
||||||
import androidx.compose.material.icons.twotone.Verified
|
import androidx.compose.material.icons.twotone.Verified
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -45,7 +41,6 @@ import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
|
|
@ -56,7 +51,6 @@ import org.meshtastic.core.strings.device
|
||||||
import org.meshtastic.core.strings.hardware
|
import org.meshtastic.core.strings.hardware
|
||||||
import org.meshtastic.core.strings.supported
|
import org.meshtastic.core.strings.supported
|
||||||
import org.meshtastic.core.strings.supported_by_community
|
import org.meshtastic.core.strings.supported_by_community
|
||||||
import org.meshtastic.core.ui.component.InsetDivider
|
|
||||||
import org.meshtastic.core.ui.component.ListItem
|
import org.meshtastic.core.ui.component.ListItem
|
||||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||||
|
|
@ -67,21 +61,9 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
|
||||||
val node = state.node ?: return
|
val node = state.node ?: return
|
||||||
val deviceHardware = state.deviceHardware ?: return
|
val deviceHardware = state.deviceHardware ?: return
|
||||||
|
|
||||||
ElevatedCard(
|
SectionCard(title = Res.string.device, modifier = modifier) {
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.elevatedCardColors(containerColor = colorScheme.surfaceContainerHigh),
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
) {
|
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Column(modifier = Modifier.padding(vertical = 16.dp)) {
|
Column {
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.device),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
|
@ -90,7 +72,7 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
InsetDivider()
|
SectionDivider()
|
||||||
val deviceText =
|
val deviceText =
|
||||||
state.reportedTarget?.let { target -> "${deviceHardware.displayName} ($target)" }
|
state.reportedTarget?.let { target -> "${deviceHardware.displayName} ($target)" }
|
||||||
?: deviceHardware.displayName
|
?: deviceHardware.displayName
|
||||||
|
|
@ -102,7 +84,7 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
|
||||||
trailingIcon = null,
|
trailingIcon = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
InsetDivider()
|
SectionDivider()
|
||||||
|
|
||||||
SupportStatusItem(deviceHardware.activelySupported)
|
SupportStatusItem(deviceHardware.activelySupported)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,13 +143,6 @@ internal fun EnvironmentMetrics(
|
||||||
if (hasWeight()) {
|
if (hasWeight()) {
|
||||||
add(VectorMetricInfo(Res.string.weight, "%.2f kg".format(weight), Icons.Default.Scale))
|
add(VectorMetricInfo(Res.string.weight, "%.2f kg".format(weight), Icons.Default.Scale))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val drawableMetrics =
|
|
||||||
remember(node.environmentMetrics, isFahrenheit) {
|
|
||||||
buildList {
|
|
||||||
with(node.environmentMetrics) {
|
|
||||||
if (hasTemperature() && hasRelativeHumidity()) {
|
if (hasTemperature() && hasRelativeHumidity()) {
|
||||||
val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity)
|
val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity)
|
||||||
add(
|
add(
|
||||||
|
|
@ -196,20 +189,21 @@ internal fun EnvironmentMetrics(
|
||||||
verticalArrangement = Arrangement.SpaceEvenly,
|
verticalArrangement = Arrangement.SpaceEvenly,
|
||||||
) {
|
) {
|
||||||
vectorMetrics.forEach { metric ->
|
vectorMetrics.forEach { metric ->
|
||||||
InfoCard(
|
if (metric is DrawableMetricInfo) {
|
||||||
icon = metric.icon,
|
DrawableInfoCard(
|
||||||
text = stringResource(metric.label),
|
iconRes = metric.icon,
|
||||||
value = metric.value,
|
text = stringResource(metric.label),
|
||||||
rotateIcon = metric.rotateIcon,
|
value = metric.value,
|
||||||
)
|
rotateIcon = metric.rotateIcon,
|
||||||
}
|
)
|
||||||
drawableMetrics.forEach { metric ->
|
} else if (metric is VectorMetricInfo) {
|
||||||
DrawableInfoCard(
|
InfoCard(
|
||||||
iconRes = metric.icon,
|
icon = metric.icon,
|
||||||
text = stringResource(metric.label),
|
text = stringResource(metric.label),
|
||||||
value = metric.value,
|
value = metric.value,
|
||||||
rotateIcon = metric.rotateIcon,
|
rotateIcon = metric.rotateIcon,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,53 +14,104 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.component
|
package org.meshtastic.feature.node.component
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.Clipboard
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.meshtastic.core.strings.Res
|
||||||
|
import org.meshtastic.core.strings.copy
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoCard(icon: ImageVector, text: String, value: String, modifier: Modifier = Modifier, rotateIcon: Float = 0f) {
|
fun InfoCard(
|
||||||
Card(modifier = modifier.padding(4.dp).width(100.dp).height(100.dp)) {
|
text: String,
|
||||||
Box(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp), contentAlignment = Alignment.Center) {
|
value: String,
|
||||||
Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
icon: ImageVector? = null,
|
||||||
|
@DrawableRes iconRes: Int? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
rotateIcon: Float = 0f,
|
||||||
|
) {
|
||||||
|
val clipboard: Clipboard = LocalClipboard.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val shape = MaterialTheme.shapes.medium
|
||||||
|
val copyLabel = stringResource(Res.string.copy)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.defaultMinSize(minHeight = 48.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = {
|
||||||
|
coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(text, value))) }
|
||||||
|
},
|
||||||
|
onLongClickLabel = copyLabel,
|
||||||
|
onClick = {},
|
||||||
|
role = Role.Button,
|
||||||
|
)
|
||||||
|
.semantics(mergeDescendants = true) { contentDescription = "$text: $value" },
|
||||||
|
shape = shape,
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val iconModifier = Modifier.size(20.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) }
|
||||||
|
val iconTint = MaterialTheme.colorScheme.primary
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(imageVector = icon, contentDescription = null, modifier = iconModifier, tint = iconTint)
|
||||||
|
}
|
||||||
|
if (iconRes != null) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
painter = painterResource(iconRes),
|
||||||
contentDescription = text,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(24.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) },
|
modifier = iconModifier,
|
||||||
|
tint = iconTint,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
Text(
|
Text(
|
||||||
textAlign = TextAlign.Center,
|
text,
|
||||||
text = text,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = value,
|
value,
|
||||||
maxLines = 1,
|
style = MaterialTheme.typography.labelLargeEmphasized,
|
||||||
overflow = TextOverflow.Ellipsis,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,30 +120,7 @@ fun InfoCard(icon: ImageVector, text: String, value: String, modifier: Modifier
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DrawableInfoCard(@DrawableRes iconRes: Int, text: String, value: String, rotateIcon: Float = 0f) {
|
internal fun DrawableInfoCard(@DrawableRes iconRes: Int, text: String, value: String, rotateIcon: Float = 0f) {
|
||||||
Card(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp)) {
|
InfoCard(iconRes = iconRes, text = text, value = value, rotateIcon = rotateIcon)
|
||||||
Box(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp), contentAlignment = Alignment.Center) {
|
|
||||||
Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = iconRes),
|
|
||||||
contentDescription = text,
|
|
||||||
modifier = Modifier.size(24.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) },
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
text = text,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun Modifier.thenIf(precondition: Boolean, action: Modifier.() -> Modifier): Modifier =
|
inline fun Modifier.thenIf(precondition: Boolean, action: Modifier.() -> Modifier): Modifier =
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,16 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.LocationOn
|
import androidx.compose.material.icons.filled.LocationOn
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.Clipboard
|
import androidx.compose.ui.platform.Clipboard
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
|
@ -39,13 +45,13 @@ import org.meshtastic.core.model.util.GPSFormat
|
||||||
import org.meshtastic.core.model.util.metersIn
|
import org.meshtastic.core.model.util.metersIn
|
||||||
import org.meshtastic.core.model.util.toString
|
import org.meshtastic.core.model.util.toString
|
||||||
import org.meshtastic.core.strings.Res
|
import org.meshtastic.core.strings.Res
|
||||||
|
import org.meshtastic.core.strings.copy
|
||||||
import org.meshtastic.core.strings.elevation_suffix
|
import org.meshtastic.core.strings.elevation_suffix
|
||||||
import org.meshtastic.core.strings.last_position_update
|
import org.meshtastic.core.strings.last_position_update
|
||||||
import org.meshtastic.core.ui.component.BasicListItem
|
import org.meshtastic.core.ui.component.BasicListItem
|
||||||
import org.meshtastic.core.ui.component.icon
|
import org.meshtastic.core.ui.component.icon
|
||||||
import org.meshtastic.core.ui.theme.AppTheme
|
import org.meshtastic.core.ui.theme.AppTheme
|
||||||
import org.meshtastic.core.ui.util.formatAgo
|
import org.meshtastic.core.ui.util.formatAgo
|
||||||
import org.meshtastic.core.ui.util.showToast
|
|
||||||
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
|
@ -64,7 +70,22 @@ fun LinkedCoordinatesItem(node: Node, displayUnits: DisplayUnits = DisplayUnits.
|
||||||
" • ${altitude.metersIn(displayUnits).toString(displayUnits)} $suffix"
|
" • ${altitude.metersIn(displayUnits).toString(displayUnits)} $suffix"
|
||||||
} ?: ""
|
} ?: ""
|
||||||
|
|
||||||
|
val copyLabel = stringResource(Res.string.copy)
|
||||||
|
|
||||||
BasicListItem(
|
BasicListItem(
|
||||||
|
modifier =
|
||||||
|
Modifier.semantics {
|
||||||
|
role = Role.Button
|
||||||
|
customActions =
|
||||||
|
listOf(
|
||||||
|
CustomAccessibilityAction(copyLabel) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", coordinates)))
|
||||||
|
}
|
||||||
|
true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
text = stringResource(Res.string.last_position_update),
|
text = stringResource(Res.string.last_position_update),
|
||||||
leadingIcon = Icons.Default.LocationOn,
|
leadingIcon = Icons.Default.LocationOn,
|
||||||
supportingText = "$ago • $coordinates$elevationText",
|
supportingText = "$ago • $coordinates$elevationText",
|
||||||
|
|
@ -77,8 +98,6 @@ fun LinkedCoordinatesItem(node: Node, displayUnits: DisplayUnits = DisplayUnits.
|
||||||
try {
|
try {
|
||||||
if (intent.resolveActivity(context.packageManager) != null) {
|
if (intent.resolveActivity(context.packageManager) != null) {
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
} else {
|
|
||||||
coroutineScope.launch { context.showToast("No application available to open this location!") }
|
|
||||||
}
|
}
|
||||||
} catch (ex: ActivityNotFoundException) {
|
} catch (ex: ActivityNotFoundException) {
|
||||||
Logger.d { "Failed to open geo intent: $ex" }
|
Logger.d { "Failed to open geo intent: $ex" }
|
||||||
|
|
|
||||||
|
|
@ -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
|
package org.meshtastic.feature.node.component
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import android.content.ClipData
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.KeyOff
|
import androidx.compose.material.icons.filled.KeyOff
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Numbers
|
import androidx.compose.material.icons.filled.Numbers
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.SignalCellularAlt
|
||||||
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.material.icons.filled.Work
|
import androidx.compose.material.icons.filled.Work
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.Clipboard
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.meshtastic.core.database.model.Node
|
import org.meshtastic.core.database.model.Node
|
||||||
|
import org.meshtastic.core.model.DataPacket
|
||||||
import org.meshtastic.core.model.util.formatUptime
|
import org.meshtastic.core.model.util.formatUptime
|
||||||
import org.meshtastic.core.strings.Res
|
import org.meshtastic.core.strings.Res
|
||||||
|
import org.meshtastic.core.strings.copy
|
||||||
import org.meshtastic.core.strings.details
|
import org.meshtastic.core.strings.details
|
||||||
import org.meshtastic.core.strings.encryption_error
|
import org.meshtastic.core.strings.encryption_error
|
||||||
import org.meshtastic.core.strings.encryption_error_text
|
import org.meshtastic.core.strings.encryption_error_text
|
||||||
|
import org.meshtastic.core.strings.hops_away
|
||||||
|
import org.meshtastic.core.strings.node_id
|
||||||
import org.meshtastic.core.strings.node_number
|
import org.meshtastic.core.strings.node_number
|
||||||
import org.meshtastic.core.strings.node_sort_last_heard
|
import org.meshtastic.core.strings.node_sort_last_heard
|
||||||
|
import org.meshtastic.core.strings.public_key
|
||||||
import org.meshtastic.core.strings.role
|
import org.meshtastic.core.strings.role
|
||||||
|
import org.meshtastic.core.strings.rssi
|
||||||
import org.meshtastic.core.strings.short_name
|
import org.meshtastic.core.strings.short_name
|
||||||
|
import org.meshtastic.core.strings.snr
|
||||||
|
import org.meshtastic.core.strings.supported
|
||||||
import org.meshtastic.core.strings.uptime
|
import org.meshtastic.core.strings.uptime
|
||||||
import org.meshtastic.core.strings.user_id
|
import org.meshtastic.core.strings.user_id
|
||||||
|
import org.meshtastic.core.strings.via_mqtt
|
||||||
import org.meshtastic.core.ui.util.formatAgo
|
import org.meshtastic.core.ui.util.formatAgo
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) {
|
fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) {
|
||||||
ElevatedCard(
|
SectionCard(title = Res.string.details, modifier = modifier) {
|
||||||
modifier = modifier.fillMaxWidth(),
|
Column {
|
||||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
if (node.mismatchKey) {
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
MismatchKeyWarning(Modifier.padding(horizontal = 16.dp))
|
||||||
) {
|
Spacer(Modifier.height(16.dp))
|
||||||
SelectionContainer {
|
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.details),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
|
|
||||||
if (node.mismatchKey) {
|
|
||||||
MismatchKeyWarning()
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
MainNodeDetails(node)
|
|
||||||
}
|
}
|
||||||
|
MainNodeDetails(node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MismatchKeyWarning() {
|
private fun MismatchKeyWarning(modifier: Modifier = Modifier) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.KeyOff,
|
imageVector = Icons.Default.KeyOff,
|
||||||
contentDescription = stringResource(Res.string.encryption_error),
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
@ -125,68 +130,189 @@ private fun MismatchKeyWarning() {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MainNodeDetails(node: Node) {
|
private fun MainNodeDetails(node: Node) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
NameAndRoleRow(node)
|
||||||
InfoItem(
|
SectionDivider()
|
||||||
label = stringResource(Res.string.short_name),
|
NodeIdentificationRow(node)
|
||||||
value = node.user.shortName.ifEmpty { "???" },
|
SectionDivider()
|
||||||
icon = Icons.Default.Person,
|
HearsAndHopsRow(node)
|
||||||
modifier = Modifier.weight(1f),
|
SectionDivider()
|
||||||
)
|
UserAndUptimeRow(node)
|
||||||
InfoItem(
|
SectionDivider()
|
||||||
label = stringResource(Res.string.role),
|
SignalRow(node)
|
||||||
value = node.user.role.name,
|
if (node.viaMqtt || node.manuallyVerified) {
|
||||||
icon = Icons.Default.Work,
|
SectionDivider()
|
||||||
modifier = Modifier.weight(1f),
|
MqttAndVerificationRow(node)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
val publicKey = node.publicKey ?: node.user.publicKey
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
if (!publicKey.isEmpty) {
|
||||||
|
SectionDivider()
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
PublicKeyItem(publicKey.toByteArray())
|
||||||
InfoItem(
|
|
||||||
label = stringResource(Res.string.node_sort_last_heard),
|
|
||||||
value = formatAgo(node.lastHeard),
|
|
||||||
icon = Icons.Default.History,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
InfoItem(
|
|
||||||
label = stringResource(Res.string.node_number),
|
|
||||||
value = node.num.toUInt().toString(),
|
|
||||||
icon = Icons.Default.Numbers,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
InfoItem(
|
|
||||||
label = stringResource(Res.string.user_id),
|
|
||||||
value = node.user.id,
|
|
||||||
icon = Icons.Default.Person,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (node.deviceMetrics.uptimeSeconds > 0) {
|
|
||||||
InfoItem(
|
|
||||||
label = stringResource(Res.string.uptime),
|
|
||||||
value = formatUptime(node.deviceMetrics.uptimeSeconds),
|
|
||||||
icon = Icons.Default.CheckCircle,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InfoItem(label: String, value: String, icon: ImageVector, modifier: Modifier = Modifier) {
|
private fun NameAndRoleRow(node: Node) {
|
||||||
Column(modifier = modifier) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.short_name),
|
||||||
|
value = node.user.shortName.ifEmpty { "???" },
|
||||||
|
icon = Icons.Default.Person,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.role),
|
||||||
|
value = node.user.role.name,
|
||||||
|
icon = Icons.Default.Work,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NodeIdentificationRow(node: Node) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.node_id),
|
||||||
|
value = DataPacket.nodeNumToDefaultId(node.num),
|
||||||
|
icon = Icons.Default.Numbers,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.node_number),
|
||||||
|
value = node.num.toUInt().toString(),
|
||||||
|
icon = Icons.Default.Numbers,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HearsAndHopsRow(node: Node) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.node_sort_last_heard),
|
||||||
|
value = formatAgo(node.lastHeard),
|
||||||
|
icon = Icons.Default.History,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (node.hopsAway >= 0) {
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.hops_away),
|
||||||
|
value = node.hopsAway.toString(),
|
||||||
|
icon = Icons.Default.SignalCellularAlt,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UserAndUptimeRow(node: Node) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.user_id),
|
||||||
|
value = node.user.id,
|
||||||
|
icon = Icons.Default.Person,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (node.deviceMetrics.uptimeSeconds > 0) {
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.uptime),
|
||||||
|
value = formatUptime(node.deviceMetrics.uptimeSeconds),
|
||||||
|
icon = Icons.Default.CheckCircle,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SignalRow(node: Node) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
if (node.snr != Float.MAX_VALUE) {
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.snr),
|
||||||
|
value = "%.1f dB".format(node.snr),
|
||||||
|
icon = Icons.Default.SignalCellularAlt,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
if (node.rssi != Int.MAX_VALUE) {
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.rssi),
|
||||||
|
value = "%d dBm".format(node.rssi),
|
||||||
|
icon = Icons.Default.SignalCellularAlt,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MqttAndVerificationRow(node: Node) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
if (node.viaMqtt) {
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.via_mqtt),
|
||||||
|
value = "Yes",
|
||||||
|
icon = Icons.Default.Cloud,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
if (node.manuallyVerified) {
|
||||||
|
InfoItem(
|
||||||
|
label = stringResource(Res.string.supported),
|
||||||
|
value = "Verified",
|
||||||
|
icon = Icons.Default.Verified,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun PublicKeyItem(publicKeyBytes: ByteArray) {
|
||||||
|
val clipboard: Clipboard = LocalClipboard.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.DEFAULT).trim()
|
||||||
|
val label = stringResource(Res.string.public_key)
|
||||||
|
val copyLabel = stringResource(Res.string.copy)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.defaultMinSize(minHeight = 48.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, publicKeyBase64)))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClickLabel = copyLabel,
|
||||||
|
onClick = {},
|
||||||
|
role = Role.Button,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 8.dp)
|
||||||
|
.semantics(mergeDescendants = true) { contentDescription = "$label: $publicKeyBase64" },
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = Icons.Default.Lock,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(14.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
|
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
|
||||||
|
|
@ -196,14 +322,13 @@ private fun InfoItem(label: String, value: String, icon: ImageVector, modifier:
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = value,
|
text = publicKeyBase64,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.component
|
package org.meshtastic.feature.node.component
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -36,8 +35,6 @@ import androidx.compose.material.icons.filled.SocialDistance
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -46,7 +43,6 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
@ -61,6 +57,9 @@ import org.meshtastic.feature.node.model.MetricsState
|
||||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||||
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
||||||
|
|
||||||
|
private const val EXCHANGE_BUTTON_WEIGHT = 1.1f
|
||||||
|
private const val COMPASS_BUTTON_WEIGHT = 0.9f
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays node position details, last update time, distance, and related actions like requesting position and
|
* Displays node position details, last update time, distance, and related actions like requesting position and
|
||||||
* accessing map/position logs.
|
* accessing map/position logs.
|
||||||
|
|
@ -76,21 +75,9 @@ fun PositionSection(
|
||||||
) {
|
) {
|
||||||
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits)
|
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits)
|
||||||
val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0
|
val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0
|
||||||
ElevatedCard(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.position),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
|
SectionCard(title = Res.string.position, modifier = modifier) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
if (hasValidPosition) {
|
if (hasValidPosition) {
|
||||||
PositionMap(node, distance)
|
PositionMap(node, distance)
|
||||||
LinkedCoordinatesItem(node, metricsState.displayUnits)
|
LinkedCoordinatesItem(node, metricsState.displayUnits)
|
||||||
|
|
@ -168,7 +155,7 @@ private fun PositionActionButtons(
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
|
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT),
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
colors =
|
colors =
|
||||||
ButtonDefaults.buttonColors(
|
ButtonDefaults.buttonColors(
|
||||||
|
|
@ -176,20 +163,30 @@ private fun PositionActionButtons(
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.LocationOn, null, Modifier.size(20.dp))
|
Icon(Icons.Default.LocationOn, null, Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(text = stringResource(Res.string.exchange_position), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(
|
||||||
|
text = stringResource(Res.string.exchange_position),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Visible,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasValidPosition) {
|
if (hasValidPosition) {
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
|
onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(COMPASS_BUTTON_WEIGHT),
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Explore, null, Modifier.size(20.dp))
|
Icon(Icons.Default.Explore, null, Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(text = stringResource(Res.string.open_compass), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(
|
||||||
|
text = stringResource(Res.string.open_compass),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,11 +14,9 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.component
|
package org.meshtastic.feature.node.component
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
@ -65,15 +63,11 @@ internal fun PowerMetrics(node: Node) {
|
||||||
}
|
}
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalArrangement = Arrangement.SpaceEvenly,
|
verticalArrangement = Arrangement.SpaceEvenly,
|
||||||
) {
|
) {
|
||||||
metrics.chunked(2).forEach { rowMetrics ->
|
metrics.forEach { metric ->
|
||||||
Column {
|
InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value)
|
||||||
rowMetrics.forEach { metric ->
|
|
||||||
InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 nodeManagementActions: NodeManagementActions,
|
||||||
private val nodeRequestActions: NodeRequestActions,
|
private val nodeRequestActions: NodeRequestActions,
|
||||||
) {
|
) {
|
||||||
private var scope: CoroutineScope? = null
|
fun handleNodeMenuAction(scope: CoroutineScope, action: NodeMenuAction) {
|
||||||
|
|
||||||
fun start(coroutineScope: CoroutineScope) {
|
|
||||||
scope = coroutineScope
|
|
||||||
nodeManagementActions.start(coroutineScope)
|
|
||||||
nodeRequestActions.start(coroutineScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleNodeMenuAction(action: NodeMenuAction) {
|
|
||||||
when (action) {
|
when (action) {
|
||||||
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num)
|
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(scope, action.node.num)
|
||||||
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node)
|
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(scope, action.node)
|
||||||
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node)
|
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(scope, action.node)
|
||||||
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node)
|
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(scope, action.node)
|
||||||
is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num)
|
is NodeMenuAction.RequestUserInfo ->
|
||||||
is NodeMenuAction.RequestNeighborInfo -> nodeRequestActions.requestNeighborInfo(action.node.num)
|
nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.longName)
|
||||||
is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num)
|
is NodeMenuAction.RequestNeighborInfo ->
|
||||||
is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type)
|
nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.longName)
|
||||||
is NodeMenuAction.TraceRoute -> nodeRequestActions.requestTraceroute(action.node.num)
|
is NodeMenuAction.RequestPosition ->
|
||||||
|
nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.longName)
|
||||||
|
is NodeMenuAction.RequestTelemetry ->
|
||||||
|
nodeRequestActions.requestTelemetry(scope, action.node.num, action.node.user.longName, action.type)
|
||||||
|
is NodeMenuAction.TraceRoute ->
|
||||||
|
nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.longName)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setNodeNotes(nodeNum: Int, notes: String) {
|
fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) {
|
||||||
nodeManagementActions.setNodeNotes(nodeNum, notes)
|
nodeManagementActions.setNodeNotes(scope, nodeNum, notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestPosition(destNum: Int, position: Position) {
|
fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) {
|
||||||
nodeRequestActions.requestPosition(destNum, position)
|
nodeRequestActions.requestPosition(scope, destNum, longName, position)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestUserInfo(destNum: Int) {
|
fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
|
||||||
nodeRequestActions.requestUserInfo(destNum)
|
nodeRequestActions.requestUserInfo(scope, destNum, longName)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestNeighborInfo(destNum: Int) {
|
fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
|
||||||
nodeRequestActions.requestNeighborInfo(destNum)
|
nodeRequestActions.requestNeighborInfo(scope, destNum, longName)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestTelemetry(destNum: Int, type: TelemetryType) {
|
fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
|
||||||
nodeRequestActions.requestTelemetry(destNum, type)
|
nodeRequestActions.requestTelemetry(scope, destNum, longName, type)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestTraceroute(destNum: Int) {
|
fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
|
||||||
nodeRequestActions.requestTraceroute(destNum)
|
nodeRequestActions.requestTraceroute(scope, destNum, longName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.detail
|
package org.meshtastic.feature.node.detail
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
|
@ -59,7 +58,6 @@ import org.meshtastic.feature.node.component.CompassSheetContent
|
||||||
import org.meshtastic.feature.node.component.DeviceActions
|
import org.meshtastic.feature.node.component.DeviceActions
|
||||||
import org.meshtastic.feature.node.component.DeviceDetailsSection
|
import org.meshtastic.feature.node.component.DeviceDetailsSection
|
||||||
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
|
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
|
||||||
import org.meshtastic.feature.node.component.MetricsSection
|
|
||||||
import org.meshtastic.feature.node.component.NodeDetailsSection
|
import org.meshtastic.feature.node.component.NodeDetailsSection
|
||||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||||
import org.meshtastic.feature.node.component.NotesSection
|
import org.meshtastic.feature.node.component.NotesSection
|
||||||
|
|
@ -126,7 +124,7 @@ fun NodeDetailList(
|
||||||
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
|
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
|
||||||
val compassUiState by
|
val compassUiState by
|
||||||
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
|
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
|
||||||
var compassTargetNode by remember { mutableStateOf<Node?>(null) } // Cache target for sheet-side position requests
|
var compassTargetNode by remember { mutableStateOf<Node?>(null) }
|
||||||
|
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
|
||||||
|
|
@ -164,7 +162,9 @@ fun NodeDetailList(
|
||||||
lastTracerouteTime = lastTracerouteTime,
|
lastTracerouteTime = lastTracerouteTime,
|
||||||
lastRequestNeighborsTime = lastRequestNeighborsTime,
|
lastRequestNeighborsTime = lastRequestNeighborsTime,
|
||||||
node = node,
|
node = node,
|
||||||
|
availableLogs = availableLogs,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
|
metricsState = metricsState,
|
||||||
)
|
)
|
||||||
|
|
||||||
PositionSection(
|
PositionSection(
|
||||||
|
|
@ -189,8 +189,6 @@ fun NodeDetailList(
|
||||||
DeviceDetailsSection(metricsState)
|
DeviceDetailsSection(metricsState)
|
||||||
}
|
}
|
||||||
|
|
||||||
MetricsSection(node, metricsState, availableLogs, onAction)
|
|
||||||
|
|
||||||
NotesSection(node = node, onSaveNotes = onSaveNotes)
|
NotesSection(node = node, onSaveNotes = onSaveNotes)
|
||||||
|
|
||||||
if (!metricsState.isManaged) {
|
if (!metricsState.isManaged) {
|
||||||
|
|
@ -231,7 +229,6 @@ private fun CompassSheetHost(
|
||||||
onRequestPosition: () -> Unit,
|
onRequestPosition: () -> Unit,
|
||||||
) {
|
) {
|
||||||
if (showCompassSheet && compassViewModel != null) {
|
if (showCompassSheet && compassViewModel != null) {
|
||||||
// Tie sensor lifecycle to the sheet so streams stop as soon as the sheet is dismissed.
|
|
||||||
DisposableEffect(Unit) { onDispose { compassViewModel.stop() } }
|
DisposableEffect(Unit) { onDispose { compassViewModel.stop() } }
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||||
|
|
|
||||||
|
|
@ -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
|
package org.meshtastic.feature.node.detail
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.meshtastic.core.strings.getString
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||||
import org.meshtastic.core.database.model.Node
|
import org.meshtastic.core.database.model.Node
|
||||||
import org.meshtastic.core.model.DataPacket
|
|
||||||
import org.meshtastic.core.navigation.Route
|
import org.meshtastic.core.navigation.Route
|
||||||
|
import org.meshtastic.core.strings.Res
|
||||||
|
import org.meshtastic.core.strings.loading
|
||||||
import org.meshtastic.core.ui.component.MainAppBar
|
import org.meshtastic.core.ui.component.MainAppBar
|
||||||
|
import org.meshtastic.core.ui.component.SharedContactDialog
|
||||||
|
import org.meshtastic.feature.node.compass.CompassUiState
|
||||||
|
import org.meshtastic.feature.node.compass.CompassViewModel
|
||||||
|
import org.meshtastic.feature.node.component.AdministrationSection
|
||||||
|
import org.meshtastic.feature.node.component.CompassSheetContent
|
||||||
|
import org.meshtastic.feature.node.component.DeviceActions
|
||||||
|
import org.meshtastic.feature.node.component.DeviceDetailsSection
|
||||||
|
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
|
||||||
|
import org.meshtastic.feature.node.component.NodeDetailsSection
|
||||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||||
import org.meshtastic.feature.node.metrics.MetricsViewModel
|
import org.meshtastic.feature.node.component.NotesSection
|
||||||
import org.meshtastic.feature.node.model.LogsType
|
import org.meshtastic.feature.node.component.PositionSection
|
||||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||||
|
|
||||||
@Suppress("LongMethod")
|
private sealed interface NodeDetailOverlay {
|
||||||
|
data object SharedContact : NodeDetailOverlay
|
||||||
|
|
||||||
|
data class FirmwareReleaseInfo(val release: FirmwareRelease) : NodeDetailOverlay
|
||||||
|
|
||||||
|
data object Compass : NodeDetailOverlay
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NodeDetailScreen(
|
fun NodeDetailScreen(
|
||||||
nodeId: Int,
|
nodeId: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
metricsViewModel: MetricsViewModel = hiltViewModel(),
|
viewModel: NodeDetailViewModel = hiltViewModel(),
|
||||||
nodeDetailViewModel: NodeDetailViewModel = hiltViewModel(),
|
|
||||||
navigateToMessages: (String) -> Unit = {},
|
navigateToMessages: (String) -> Unit = {},
|
||||||
onNavigate: (Route) -> Unit = {},
|
onNavigate: (Route) -> Unit = {},
|
||||||
onNavigateUp: () -> Unit = {},
|
onNavigateUp: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(nodeId) { metricsViewModel.setNodeId(nodeId) }
|
viewModel.start(nodeId)
|
||||||
|
|
||||||
val metricsState by metricsViewModel.state.collectAsStateWithLifecycle()
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val environmentMetricsState by metricsViewModel.environmentState.collectAsStateWithLifecycle()
|
LaunchedEffect(Unit) {
|
||||||
val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
|
viewModel.effects.collect { effect ->
|
||||||
val lastRequestNeighborsTime by nodeDetailViewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle()
|
if (effect is NodeRequestEffect.ShowFeedback) {
|
||||||
val ourNode by nodeDetailViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
@Suppress("SpreadOperator")
|
||||||
|
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
|
||||||
val availableLogs by
|
|
||||||
remember(metricsState, environmentMetricsState) {
|
|
||||||
derivedStateOf {
|
|
||||||
buildSet {
|
|
||||||
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
|
|
||||||
if (metricsState.hasPositionLogs()) {
|
|
||||||
add(LogsType.NODE_MAP)
|
|
||||||
add(LogsType.POSITIONS)
|
|
||||||
}
|
|
||||||
if (environmentMetricsState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
|
|
||||||
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
|
|
||||||
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
|
|
||||||
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
|
|
||||||
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
|
|
||||||
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val node = metricsState.node
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
NodeDetailScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
uiState = uiState,
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
viewModel = viewModel,
|
||||||
|
navigateToMessages = navigateToMessages,
|
||||||
|
onNavigate = onNavigate,
|
||||||
|
onNavigateUp = onNavigateUp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
private fun NodeDetailScaffold(
|
||||||
|
modifier: Modifier,
|
||||||
|
uiState: NodeDetailUiState,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
|
viewModel: NodeDetailViewModel,
|
||||||
|
navigateToMessages: (String) -> Unit,
|
||||||
|
onNavigate: (Route) -> Unit,
|
||||||
|
onNavigateUp: () -> Unit,
|
||||||
|
) {
|
||||||
|
var activeOverlay by remember { mutableStateOf<NodeDetailOverlay?>(null) }
|
||||||
|
val inspectionMode = LocalInspectionMode.current
|
||||||
|
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
|
||||||
|
val compassUiState by
|
||||||
|
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
|
||||||
|
|
||||||
|
val node = uiState.node
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
@Suppress("ModifierNotUsedAtRoot")
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
MainAppBar(
|
MainAppBar(
|
||||||
title = node?.user?.longName ?: "",
|
title = node?.user?.longName ?: "",
|
||||||
ourNode = ourNode,
|
ourNode = uiState.ourNode,
|
||||||
showNodeChip = false,
|
showNodeChip = false,
|
||||||
canNavigateUp = true,
|
canNavigateUp = true,
|
||||||
onNavigateUp = onNavigateUp,
|
onNavigateUp = onNavigateUp,
|
||||||
|
|
@ -92,74 +153,188 @@ fun NodeDetailScreen(
|
||||||
onClickChip = {},
|
onClickChip = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
if (node != null) {
|
NodeDetailContent(
|
||||||
NodeDetailContent(
|
uiState = uiState,
|
||||||
node = node,
|
viewModel = viewModel,
|
||||||
ourNode = ourNode,
|
scrollState = scrollState,
|
||||||
metricsState = metricsState,
|
onAction = { action ->
|
||||||
lastTracerouteTime = lastTracerouteTime,
|
when (action) {
|
||||||
lastRequestNeighborsTime = lastRequestNeighborsTime,
|
is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
|
||||||
availableLogs = availableLogs,
|
is NodeDetailAction.OpenCompass -> {
|
||||||
onAction = { action ->
|
compassViewModel?.start(action.node, action.displayUnits)
|
||||||
handleNodeAction(
|
activeOverlay = NodeDetailOverlay.Compass
|
||||||
action = action,
|
}
|
||||||
ourNode = ourNode,
|
else ->
|
||||||
node = node,
|
handleNodeAction(
|
||||||
navigateToMessages = navigateToMessages,
|
action = action,
|
||||||
onNavigateUp = onNavigateUp,
|
uiState = uiState,
|
||||||
onNavigate = onNavigate,
|
navigateToMessages = navigateToMessages,
|
||||||
metricsViewModel = metricsViewModel,
|
onNavigateUp = onNavigateUp,
|
||||||
nodeDetailViewModel = nodeDetailViewModel,
|
onNavigate = onNavigate,
|
||||||
)
|
viewModel = viewModel,
|
||||||
},
|
)
|
||||||
modifier = modifier.padding(paddingValues),
|
}
|
||||||
onSaveNotes = { num, notes -> nodeDetailViewModel.setNodeNotes(num, notes) },
|
},
|
||||||
|
onFirmwareSelect = { activeOverlay = NodeDetailOverlay.FirmwareReleaseInfo(it) },
|
||||||
|
modifier = Modifier.padding(paddingValues),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeDetailOverlays(activeOverlay, node, compassUiState, compassViewModel, { activeOverlay = null }) {
|
||||||
|
viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NodeDetailContent(
|
||||||
|
uiState: NodeDetailUiState,
|
||||||
|
viewModel: NodeDetailViewModel,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
onAction: (NodeDetailAction) -> Unit,
|
||||||
|
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = uiState.node != null,
|
||||||
|
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
|
||||||
|
label = "NodeDetailContent",
|
||||||
|
modifier = modifier,
|
||||||
|
) { isNodePresent ->
|
||||||
|
if (isNodePresent && uiState.node != null) {
|
||||||
|
NodeDetailList(
|
||||||
|
node = uiState.node,
|
||||||
|
ourNode = uiState.ourNode,
|
||||||
|
uiState = uiState,
|
||||||
|
scrollState = scrollState,
|
||||||
|
onAction = onAction,
|
||||||
|
onFirmwareSelect = onFirmwareSelect,
|
||||||
|
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) {
|
val loadingDescription = stringResource(Res.string.loading)
|
||||||
CircularProgressIndicator()
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.semantics { contentDescription = loadingDescription })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun NodeDetailOverlays(
|
||||||
|
overlay: NodeDetailOverlay?,
|
||||||
|
node: Node?,
|
||||||
|
compassUiState: CompassUiState,
|
||||||
|
compassViewModel: CompassViewModel?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRequestPosition: (Node) -> Unit,
|
||||||
|
) {
|
||||||
|
val permissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
|
||||||
|
val locationSettingsLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> }
|
||||||
|
|
||||||
|
when (overlay) {
|
||||||
|
is NodeDetailOverlay.SharedContact -> node?.let { SharedContactDialog(it, onDismiss) }
|
||||||
|
is NodeDetailOverlay.FirmwareReleaseInfo ->
|
||||||
|
NodeDetailBottomSheet(onDismiss) { FirmwareReleaseSheetContent(firmwareRelease = overlay.release) }
|
||||||
|
is NodeDetailOverlay.Compass -> {
|
||||||
|
DisposableEffect(Unit) { onDispose { compassViewModel?.stop() } }
|
||||||
|
NodeDetailBottomSheet(
|
||||||
|
onDismiss = {
|
||||||
|
compassViewModel?.stop()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
CompassSheetContent(
|
||||||
|
uiState = compassUiState,
|
||||||
|
onRequestLocationPermission = {
|
||||||
|
val perms =
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||||
|
)
|
||||||
|
permissionLauncher.launch(perms)
|
||||||
|
},
|
||||||
|
onOpenLocationSettings = {
|
||||||
|
locationSettingsLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
|
||||||
|
},
|
||||||
|
onRequestPosition = { node?.let { onRequestPosition(it) } },
|
||||||
|
modifier = Modifier.padding(bottom = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NodeDetailList(
|
||||||
|
node: Node,
|
||||||
|
ourNode: Node?,
|
||||||
|
uiState: NodeDetailUiState,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
onAction: (NodeDetailAction) -> Unit,
|
||||||
|
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||||
|
onSaveNotes: (Int, String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().verticalScroll(scrollState).padding(16.dp).focusable(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
NodeDetailsSection(node)
|
||||||
|
DeviceActions(
|
||||||
|
node = node,
|
||||||
|
lastTracerouteTime = uiState.lastTracerouteTime,
|
||||||
|
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
|
||||||
|
availableLogs = uiState.availableLogs,
|
||||||
|
onAction = onAction,
|
||||||
|
metricsState = uiState.metricsState,
|
||||||
|
isLocal = uiState.metricsState.isLocal,
|
||||||
|
)
|
||||||
|
PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction)
|
||||||
|
if (uiState.metricsState.deviceHardware != null) DeviceDetailsSection(uiState.metricsState)
|
||||||
|
NotesSection(node = node, onSaveNotes = onSaveNotes)
|
||||||
|
if (!uiState.metricsState.isManaged) {
|
||||||
|
AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleNodeAction(
|
private fun handleNodeAction(
|
||||||
action: NodeDetailAction,
|
action: NodeDetailAction,
|
||||||
ourNode: Node?,
|
uiState: NodeDetailUiState,
|
||||||
node: Node,
|
|
||||||
navigateToMessages: (String) -> Unit,
|
navigateToMessages: (String) -> Unit,
|
||||||
onNavigateUp: () -> Unit,
|
onNavigateUp: () -> Unit,
|
||||||
onNavigate: (Route) -> Unit,
|
onNavigate: (Route) -> Unit,
|
||||||
metricsViewModel: MetricsViewModel,
|
viewModel: NodeDetailViewModel,
|
||||||
nodeDetailViewModel: NodeDetailViewModel,
|
|
||||||
) {
|
) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is NodeDetailAction.Navigate -> onNavigate(action.route)
|
is NodeDetailAction.Navigate -> onNavigate(action.route)
|
||||||
is NodeDetailAction.TriggerServiceAction -> metricsViewModel.onServiceAction(action.action)
|
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
|
||||||
is NodeDetailAction.HandleNodeMenuAction -> {
|
is NodeDetailAction.HandleNodeMenuAction -> {
|
||||||
when (val menuAction = action.action) {
|
when (val menuAction = action.action) {
|
||||||
is NodeMenuAction.DirectMessage -> {
|
is NodeMenuAction.DirectMessage -> {
|
||||||
val hasPKC = ourNode?.hasPKC == true
|
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
|
||||||
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
|
navigateToMessages(route)
|
||||||
navigateToMessages("${channel}${node.user.id}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is NodeMenuAction.Remove -> {
|
is NodeMenuAction.Remove -> {
|
||||||
nodeDetailViewModel.handleNodeMenuAction(menuAction)
|
viewModel.handleNodeMenuAction(menuAction)
|
||||||
onNavigateUp()
|
onNavigateUp()
|
||||||
}
|
}
|
||||||
|
else -> viewModel.handleNodeMenuAction(menuAction)
|
||||||
else -> nodeDetailViewModel.handleNodeMenuAction(menuAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else -> {}
|
||||||
is NodeDetailAction.ShareContact -> {
|
|
||||||
/* Handled in NodeDetailContent */
|
|
||||||
}
|
|
||||||
|
|
||||||
is NodeDetailAction.OpenCompass -> {
|
|
||||||
/* Handled in NodeDetailList */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,61 +16,128 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.node.detail
|
package org.meshtastic.feature.node.detail
|
||||||
|
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation.toRoute
|
||||||
|
import app.cash.molecule.RecompositionMode
|
||||||
|
import app.cash.molecule.launchMolecule
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.meshtastic.core.data.repository.DeviceHardwareRepository
|
||||||
|
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
|
||||||
|
import org.meshtastic.core.data.repository.MeshLogRepository
|
||||||
import org.meshtastic.core.data.repository.NodeRepository
|
import org.meshtastic.core.data.repository.NodeRepository
|
||||||
|
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||||
import org.meshtastic.core.database.model.Node
|
import org.meshtastic.core.database.model.Node
|
||||||
|
import org.meshtastic.core.model.DataPacket
|
||||||
|
import org.meshtastic.core.navigation.NodesRoutes
|
||||||
|
import org.meshtastic.core.service.ServiceAction
|
||||||
|
import org.meshtastic.core.service.ServiceRepository
|
||||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||||
|
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
|
||||||
|
import org.meshtastic.feature.node.model.LogsType
|
||||||
|
import org.meshtastic.feature.node.model.MetricsState
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class NodeDetailUiState(
|
||||||
|
val node: Node? = null,
|
||||||
|
val ourNode: Node? = null,
|
||||||
|
val metricsState: MetricsState = MetricsState.Empty,
|
||||||
|
val environmentState: EnvironmentMetricsState = EnvironmentMetricsState(),
|
||||||
|
val availableLogs: Set<LogsType> = emptySet(),
|
||||||
|
val lastTracerouteTime: Long? = null,
|
||||||
|
val lastRequestNeighborsTime: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class NodeDetailViewModel
|
class NodeDetailViewModel
|
||||||
@Inject
|
@Inject
|
||||||
|
@Suppress("LongParameterList")
|
||||||
constructor(
|
constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
private val nodeRepository: NodeRepository,
|
private val nodeRepository: NodeRepository,
|
||||||
private val nodeManagementActions: NodeManagementActions,
|
private val nodeManagementActions: NodeManagementActions,
|
||||||
private val nodeRequestActions: NodeRequestActions,
|
private val nodeRequestActions: NodeRequestActions,
|
||||||
|
private val meshLogRepository: MeshLogRepository,
|
||||||
|
private val radioConfigRepository: RadioConfigRepository,
|
||||||
|
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||||
|
private val firmwareReleaseRepository: FirmwareReleaseRepository,
|
||||||
|
private val serviceRepository: ServiceRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
init {
|
private val nodeIdFromRoute: Int? =
|
||||||
nodeManagementActions.start(viewModelScope)
|
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
|
||||||
nodeRequestActions.start(viewModelScope)
|
|
||||||
|
private val manualNodeId = MutableStateFlow<Int?>(null)
|
||||||
|
private val activeNodeId =
|
||||||
|
combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> fromRoute ?: manual }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
|
val uiState: StateFlow<NodeDetailUiState> =
|
||||||
|
viewModelScope.launchMolecule(mode = RecompositionMode.Immediate) {
|
||||||
|
val nodeId by activeNodeId.collectAsState(null)
|
||||||
|
|
||||||
|
NodeDetailPresenter(
|
||||||
|
nodeId = nodeId,
|
||||||
|
nodeRepository = nodeRepository,
|
||||||
|
meshLogRepository = meshLogRepository,
|
||||||
|
radioConfigRepository = radioConfigRepository,
|
||||||
|
deviceHardwareRepository = deviceHardwareRepository,
|
||||||
|
firmwareReleaseRepository = firmwareReleaseRepository,
|
||||||
|
nodeRequestActions = nodeRequestActions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
|
||||||
|
|
||||||
|
fun start(nodeId: Int) {
|
||||||
|
if (manualNodeId.value != nodeId) {
|
||||||
|
manualNodeId.value = nodeId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
|
|
||||||
|
|
||||||
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
|
|
||||||
val lastTraceRouteTime: StateFlow<Long?> = _lastTraceRouteTime.asStateFlow()
|
|
||||||
|
|
||||||
private val _lastRequestNeighborsTime = MutableStateFlow<Long?>(null)
|
|
||||||
val lastRequestNeighborsTime: StateFlow<Long?> = _lastRequestNeighborsTime.asStateFlow()
|
|
||||||
|
|
||||||
fun handleNodeMenuAction(action: NodeMenuAction) {
|
fun handleNodeMenuAction(action: NodeMenuAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num)
|
is NodeMenuAction.Remove -> nodeManagementActions.removeNode(viewModelScope, action.node.num)
|
||||||
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node)
|
is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(viewModelScope, action.node)
|
||||||
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node)
|
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(viewModelScope, action.node)
|
||||||
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node)
|
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(viewModelScope, action.node)
|
||||||
is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num)
|
is NodeMenuAction.RequestUserInfo ->
|
||||||
is NodeMenuAction.RequestNeighborInfo -> {
|
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.longName)
|
||||||
nodeRequestActions.requestNeighborInfo(action.node.num)
|
is NodeMenuAction.RequestNeighborInfo ->
|
||||||
_lastRequestNeighborsTime.value = System.currentTimeMillis()
|
nodeRequestActions.requestNeighborInfo(viewModelScope, action.node.num, action.node.user.longName)
|
||||||
}
|
is NodeMenuAction.RequestPosition ->
|
||||||
is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num)
|
nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.longName)
|
||||||
is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type)
|
is NodeMenuAction.RequestTelemetry ->
|
||||||
is NodeMenuAction.TraceRoute -> {
|
nodeRequestActions.requestTelemetry(
|
||||||
nodeRequestActions.requestTraceroute(action.node.num)
|
viewModelScope,
|
||||||
_lastTraceRouteTime.value = System.currentTimeMillis()
|
action.node.num,
|
||||||
}
|
action.node.user.longName,
|
||||||
|
action.type,
|
||||||
|
)
|
||||||
|
is NodeMenuAction.TraceRoute ->
|
||||||
|
nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.longName)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) }
|
||||||
|
|
||||||
fun setNodeNotes(nodeNum: Int, notes: String) {
|
fun setNodeNotes(nodeNum: Int, notes: String) {
|
||||||
nodeManagementActions.setNodeNotes(nodeNum, notes)
|
nodeManagementActions.setNodeNotes(viewModelScope, nodeNum, notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDirectMessageRoute(node: Node, ourNode: Node?): String {
|
||||||
|
val hasPKC = ourNode?.hasPKC == true
|
||||||
|
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
|
||||||
|
return "${channel}${node.user.id}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,8 @@ constructor(
|
||||||
private val nodeRepository: NodeRepository,
|
private val nodeRepository: NodeRepository,
|
||||||
private val serviceRepository: ServiceRepository,
|
private val serviceRepository: ServiceRepository,
|
||||||
) {
|
) {
|
||||||
private var scope: CoroutineScope? = null
|
fun removeNode(scope: CoroutineScope, nodeNum: Int) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
fun start(coroutineScope: CoroutineScope) {
|
|
||||||
scope = coroutineScope
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeNode(nodeNum: Int) {
|
|
||||||
scope?.launch(Dispatchers.IO) {
|
|
||||||
Logger.i { "Removing node '$nodeNum'" }
|
Logger.i { "Removing node '$nodeNum'" }
|
||||||
try {
|
try {
|
||||||
val packetId = serviceRepository.meshService?.packetId ?: return@launch
|
val packetId = serviceRepository.meshService?.packetId ?: return@launch
|
||||||
|
|
@ -54,8 +48,8 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ignoreNode(node: Node) {
|
fun ignoreNode(scope: CoroutineScope, node: Node) {
|
||||||
scope?.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
|
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
|
||||||
} catch (ex: RemoteException) {
|
} catch (ex: RemoteException) {
|
||||||
|
|
@ -64,8 +58,8 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun muteNode(node: Node) {
|
fun muteNode(scope: CoroutineScope, node: Node) {
|
||||||
scope?.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
serviceRepository.onServiceAction(ServiceAction.Mute(node))
|
serviceRepository.onServiceAction(ServiceAction.Mute(node))
|
||||||
} catch (ex: RemoteException) {
|
} catch (ex: RemoteException) {
|
||||||
|
|
@ -74,8 +68,8 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun favoriteNode(node: Node) {
|
fun favoriteNode(scope: CoroutineScope, node: Node) {
|
||||||
scope?.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
|
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
|
||||||
} catch (ex: RemoteException) {
|
} catch (ex: RemoteException) {
|
||||||
|
|
@ -84,8 +78,8 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setNodeNotes(nodeNum: Int, notes: String) {
|
fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) {
|
||||||
scope?.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
nodeRepository.setNodeNotes(nodeNum, notes)
|
nodeRepository.setNodeNotes(nodeNum, notes)
|
||||||
} catch (ex: java.io.IOException) {
|
} catch (ex: java.io.IOException) {
|
||||||
|
|
|
||||||
|
|
@ -16,78 +16,141 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.node.detail
|
package org.meshtastic.feature.node.detail
|
||||||
|
|
||||||
import android.os.RemoteException
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
import org.meshtastic.core.model.Position
|
import org.meshtastic.core.model.Position
|
||||||
import org.meshtastic.core.model.TelemetryType
|
import org.meshtastic.core.model.TelemetryType
|
||||||
import org.meshtastic.core.service.ServiceRepository
|
import org.meshtastic.core.service.ServiceRepository
|
||||||
|
import org.meshtastic.core.strings.Res
|
||||||
|
import org.meshtastic.core.strings.neighbor_info
|
||||||
|
import org.meshtastic.core.strings.position
|
||||||
|
import org.meshtastic.core.strings.request_air_quality_metrics
|
||||||
|
import org.meshtastic.core.strings.request_device_metrics
|
||||||
|
import org.meshtastic.core.strings.request_environment_metrics
|
||||||
|
import org.meshtastic.core.strings.request_host_metrics
|
||||||
|
import org.meshtastic.core.strings.request_local_stats
|
||||||
|
import org.meshtastic.core.strings.request_pax_metrics
|
||||||
|
import org.meshtastic.core.strings.request_power_metrics
|
||||||
|
import org.meshtastic.core.strings.requesting_from
|
||||||
|
import org.meshtastic.core.strings.traceroute
|
||||||
|
import org.meshtastic.core.strings.user_info
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
sealed class NodeRequestEffect {
|
||||||
|
data class ShowFeedback(val resource: StringResource, val args: List<Any> = emptyList()) : NodeRequestEffect()
|
||||||
|
}
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class NodeRequestActions @Inject constructor(private val serviceRepository: ServiceRepository) {
|
class NodeRequestActions @Inject constructor(private val serviceRepository: ServiceRepository) {
|
||||||
private var scope: CoroutineScope? = null
|
|
||||||
|
|
||||||
fun start(coroutineScope: CoroutineScope) {
|
private val _effects = MutableSharedFlow<NodeRequestEffect>()
|
||||||
scope = coroutineScope
|
val effects: SharedFlow<NodeRequestEffect> = _effects.asSharedFlow()
|
||||||
}
|
|
||||||
|
|
||||||
fun requestUserInfo(destNum: Int) {
|
private val _lastTracerouteTimes = MutableStateFlow<Map<Int, Long>>(emptyMap())
|
||||||
scope?.launch(Dispatchers.IO) {
|
val lastTracerouteTimes: StateFlow<Map<Int, Long>> = _lastTracerouteTimes.asStateFlow()
|
||||||
|
|
||||||
|
private val _lastRequestNeighborTimes = MutableStateFlow<Map<Int, Long>>(emptyMap())
|
||||||
|
val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
|
||||||
|
|
||||||
|
fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
Logger.i { "Requesting UserInfo for '$destNum'" }
|
Logger.i { "Requesting UserInfo for '$destNum'" }
|
||||||
try {
|
try {
|
||||||
serviceRepository.meshService?.requestUserInfo(destNum)
|
serviceRepository.meshService?.requestUserInfo(destNum)
|
||||||
} catch (ex: RemoteException) {
|
_effects.emit(
|
||||||
|
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.user_info, longName)),
|
||||||
|
)
|
||||||
|
} catch (ex: android.os.RemoteException) {
|
||||||
Logger.e { "Request NodeInfo error: ${ex.message}" }
|
Logger.e { "Request NodeInfo error: ${ex.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestNeighborInfo(destNum: Int) {
|
fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
|
||||||
scope?.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
Logger.i { "Requesting NeighborInfo for '$destNum'" }
|
Logger.i { "Requesting NeighborInfo for '$destNum'" }
|
||||||
try {
|
try {
|
||||||
val packetId = serviceRepository.meshService?.packetId ?: return@launch
|
val packetId = serviceRepository.meshService?.packetId ?: return@launch
|
||||||
serviceRepository.meshService?.requestNeighborInfo(packetId, destNum)
|
serviceRepository.meshService?.requestNeighborInfo(packetId, destNum)
|
||||||
} catch (ex: RemoteException) {
|
_lastRequestNeighborTimes.update { it + (destNum to System.currentTimeMillis()) }
|
||||||
|
_effects.emit(
|
||||||
|
NodeRequestEffect.ShowFeedback(
|
||||||
|
Res.string.requesting_from,
|
||||||
|
listOf(Res.string.neighbor_info, longName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (ex: android.os.RemoteException) {
|
||||||
Logger.e { "Request NeighborInfo error: ${ex.message}" }
|
Logger.e { "Request NeighborInfo error: ${ex.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
|
fun requestPosition(
|
||||||
scope?.launch(Dispatchers.IO) {
|
scope: CoroutineScope,
|
||||||
|
destNum: Int,
|
||||||
|
longName: String,
|
||||||
|
position: Position = Position(0.0, 0.0, 0),
|
||||||
|
) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
Logger.i { "Requesting position for '$destNum'" }
|
Logger.i { "Requesting position for '$destNum'" }
|
||||||
try {
|
try {
|
||||||
serviceRepository.meshService?.requestPosition(destNum, position)
|
serviceRepository.meshService?.requestPosition(destNum, position)
|
||||||
} catch (ex: RemoteException) {
|
_effects.emit(
|
||||||
|
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.position, longName)),
|
||||||
|
)
|
||||||
|
} catch (ex: android.os.RemoteException) {
|
||||||
Logger.e { "Request position error: ${ex.message}" }
|
Logger.e { "Request position error: ${ex.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestTelemetry(destNum: Int, type: TelemetryType) {
|
fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
|
||||||
scope?.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
Logger.i { "Requesting telemetry for '$destNum'" }
|
Logger.i { "Requesting telemetry for '$destNum'" }
|
||||||
try {
|
try {
|
||||||
val packetId = serviceRepository.meshService?.packetId ?: return@launch
|
val packetId = serviceRepository.meshService?.packetId ?: return@launch
|
||||||
serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal)
|
serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal)
|
||||||
} catch (ex: RemoteException) {
|
|
||||||
|
val typeRes =
|
||||||
|
when (type) {
|
||||||
|
TelemetryType.DEVICE -> Res.string.request_device_metrics
|
||||||
|
TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics
|
||||||
|
TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics
|
||||||
|
TelemetryType.POWER -> Res.string.request_power_metrics
|
||||||
|
TelemetryType.LOCAL_STATS -> Res.string.request_local_stats
|
||||||
|
TelemetryType.HOST -> Res.string.request_host_metrics
|
||||||
|
TelemetryType.PAX -> Res.string.request_pax_metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
_effects.emit(NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(typeRes, longName)))
|
||||||
|
} catch (ex: android.os.RemoteException) {
|
||||||
Logger.e { "Request telemetry error: ${ex.message}" }
|
Logger.e { "Request telemetry error: ${ex.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestTraceroute(destNum: Int) {
|
fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
|
||||||
scope?.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
Logger.i { "Requesting traceroute for '$destNum'" }
|
Logger.i { "Requesting traceroute for '$destNum'" }
|
||||||
try {
|
try {
|
||||||
val packetId = serviceRepository.meshService?.packetId ?: return@launch
|
val packetId = serviceRepository.meshService?.packetId ?: return@launch
|
||||||
serviceRepository.meshService?.requestTraceroute(packetId, destNum)
|
serviceRepository.meshService?.requestTraceroute(packetId, destNum)
|
||||||
} catch (ex: RemoteException) {
|
_lastTracerouteTimes.update { it + (destNum to System.currentTimeMillis()) }
|
||||||
|
_effects.emit(
|
||||||
|
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.traceroute, longName)),
|
||||||
|
)
|
||||||
|
} catch (ex: android.os.RemoteException) {
|
||||||
Logger.e { "Request traceroute error: ${ex.message}" }
|
Logger.e { "Request traceroute error: ${ex.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.metrics
|
package org.meshtastic.feature.node.metrics
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
|
@ -34,12 +33,18 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -59,7 +64,9 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.meshtastic.core.strings.getString
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.meshtastic.core.model.TelemetryType
|
||||||
import org.meshtastic.core.strings.Res
|
import org.meshtastic.core.strings.Res
|
||||||
import org.meshtastic.core.strings.air_util_definition
|
import org.meshtastic.core.strings.air_util_definition
|
||||||
import org.meshtastic.core.strings.air_utilization
|
import org.meshtastic.core.strings.air_utilization
|
||||||
|
|
@ -75,6 +82,7 @@ import org.meshtastic.core.ui.theme.AppTheme
|
||||||
import org.meshtastic.core.ui.theme.GraphColors.Cyan
|
import org.meshtastic.core.ui.theme.GraphColors.Cyan
|
||||||
import org.meshtastic.core.ui.theme.GraphColors.Green
|
import org.meshtastic.core.ui.theme.GraphColors.Green
|
||||||
import org.meshtastic.core.ui.theme.GraphColors.Magenta
|
import org.meshtastic.core.ui.theme.GraphColors.Magenta
|
||||||
|
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.MAX_PERCENT_VALUE
|
import org.meshtastic.feature.node.metrics.CommonCharts.MAX_PERCENT_VALUE
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||||
|
|
@ -119,13 +127,26 @@ private val LEGEND_DATA =
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
@Composable
|
@Composable
|
||||||
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||||
val data = state.deviceMetricsFiltered(selectedTimeFrame)
|
val data = state.deviceMetricsFiltered(selectedTimeFrame)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.effects.collect { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is NodeRequestEffect.ShowFeedback -> {
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
MainAppBar(
|
MainAppBar(
|
||||||
|
|
@ -134,10 +155,20 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
||||||
showNodeChip = false,
|
showNodeChip = false,
|
||||||
canNavigateUp = true,
|
canNavigateUp = true,
|
||||||
onNavigateUp = onNavigateUp,
|
onNavigateUp = onNavigateUp,
|
||||||
actions = {},
|
actions = {
|
||||||
|
if (!state.isLocal) {
|
||||||
|
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.DEVICE) }) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onClickChip = {},
|
onClickChip = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(modifier = Modifier.padding(innerPadding)) {
|
Column(modifier = Modifier.padding(innerPadding)) {
|
||||||
if (displayInfoDialog) {
|
if (displayInfoDialog) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.metrics
|
package org.meshtastic.feature.node.metrics
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -29,12 +28,18 @@ import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -48,7 +53,9 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.meshtastic.core.strings.getString
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.meshtastic.core.model.TelemetryType
|
||||||
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
|
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
|
||||||
import org.meshtastic.core.strings.Res
|
import org.meshtastic.core.strings.Res
|
||||||
import org.meshtastic.core.strings.current
|
import org.meshtastic.core.strings.current
|
||||||
|
|
@ -68,6 +75,7 @@ import org.meshtastic.core.ui.component.IndoorAirQuality
|
||||||
import org.meshtastic.core.ui.component.MainAppBar
|
import org.meshtastic.core.ui.component.MainAppBar
|
||||||
import org.meshtastic.core.ui.component.OptionLabel
|
import org.meshtastic.core.ui.component.OptionLabel
|
||||||
import org.meshtastic.core.ui.component.SlidingSelector
|
import org.meshtastic.core.ui.component.SlidingSelector
|
||||||
|
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||||
import org.meshtastic.feature.node.model.TimeFrame
|
import org.meshtastic.feature.node.model.TimeFrame
|
||||||
|
|
@ -79,10 +87,22 @@ import org.meshtastic.proto.copy
|
||||||
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
|
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||||
val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit)
|
val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit)
|
||||||
val data = graphData.metrics
|
val data = graphData.metrics
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.effects.collect { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is NodeRequestEffect.ShowFeedback -> {
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val processedTelemetries: List<Telemetry> =
|
val processedTelemetries: List<Telemetry> =
|
||||||
if (state.isFahrenheit) {
|
if (state.isFahrenheit) {
|
||||||
data.map { telemetry ->
|
data.map { telemetry ->
|
||||||
|
|
@ -110,10 +130,20 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
|
||||||
showNodeChip = false,
|
showNodeChip = false,
|
||||||
canNavigateUp = true,
|
canNavigateUp = true,
|
||||||
onNavigateUp = onNavigateUp,
|
onNavigateUp = onNavigateUp,
|
||||||
actions = {},
|
actions = {
|
||||||
|
if (!state.isLocal) {
|
||||||
|
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) }) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onClickChip = {},
|
onClickChip = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(modifier = Modifier.padding(innerPadding)) {
|
Column(modifier = Modifier.padding(innerPadding)) {
|
||||||
if (displayInfoDialog) {
|
if (displayInfoDialog) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.metrics
|
package org.meshtastic.feature.node.metrics
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
|
@ -33,16 +32,22 @@ import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.DataArray
|
import androidx.compose.material.icons.filled.DataArray
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
|
@ -53,7 +58,9 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.meshtastic.core.strings.getString
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.meshtastic.core.model.TelemetryType
|
||||||
import org.meshtastic.core.model.util.formatUptime
|
import org.meshtastic.core.model.util.formatUptime
|
||||||
import org.meshtastic.core.strings.Res
|
import org.meshtastic.core.strings.Res
|
||||||
import org.meshtastic.core.strings.disk_free_indexed
|
import org.meshtastic.core.strings.disk_free_indexed
|
||||||
|
|
@ -63,6 +70,7 @@ import org.meshtastic.core.strings.uptime
|
||||||
import org.meshtastic.core.strings.user_string
|
import org.meshtastic.core.strings.user_string
|
||||||
import org.meshtastic.core.ui.component.MainAppBar
|
import org.meshtastic.core.ui.component.MainAppBar
|
||||||
import org.meshtastic.core.ui.theme.AppTheme
|
import org.meshtastic.core.ui.theme.AppTheme
|
||||||
|
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||||
import org.meshtastic.proto.TelemetryProtos
|
import org.meshtastic.proto.TelemetryProtos
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
|
|
@ -71,6 +79,18 @@ import java.text.DecimalFormat
|
||||||
@Composable
|
@Composable
|
||||||
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||||
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
metricsViewModel.effects.collect { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is NodeRequestEffect.ShowFeedback -> {
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val hostMetrics = state.hostMetrics
|
val hostMetrics = state.hostMetrics
|
||||||
|
|
||||||
|
|
@ -82,10 +102,20 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o
|
||||||
showNodeChip = false,
|
showNodeChip = false,
|
||||||
canNavigateUp = true,
|
canNavigateUp = true,
|
||||||
onNavigateUp = onNavigateUp,
|
onNavigateUp = onNavigateUp,
|
||||||
actions = {},
|
actions = {
|
||||||
|
if (!state.isLocal) {
|
||||||
|
IconButton(onClick = { metricsViewModel.requestTelemetry(TelemetryType.HOST) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = androidx.compose.material.icons.Icons.Default.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onClickChip = {},
|
onClickChip = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import co.touchlab.kermit.Logger
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asFlow
|
import kotlinx.coroutines.flow.asFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
|
@ -47,6 +48,7 @@ import org.meshtastic.core.database.entity.MeshLog
|
||||||
import org.meshtastic.core.database.model.Node
|
import org.meshtastic.core.database.model.Node
|
||||||
import org.meshtastic.core.di.CoroutineDispatchers
|
import org.meshtastic.core.di.CoroutineDispatchers
|
||||||
import org.meshtastic.core.model.DataPacket
|
import org.meshtastic.core.model.DataPacket
|
||||||
|
import org.meshtastic.core.model.TelemetryType
|
||||||
import org.meshtastic.core.model.TracerouteMapAvailability
|
import org.meshtastic.core.model.TracerouteMapAvailability
|
||||||
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
||||||
import org.meshtastic.core.navigation.NodesRoutes
|
import org.meshtastic.core.navigation.NodesRoutes
|
||||||
|
|
@ -55,7 +57,10 @@ import org.meshtastic.core.service.ServiceRepository
|
||||||
import org.meshtastic.core.strings.Res
|
import org.meshtastic.core.strings.Res
|
||||||
import org.meshtastic.core.strings.fallback_node_name
|
import org.meshtastic.core.strings.fallback_node_name
|
||||||
import org.meshtastic.core.ui.util.toPosition
|
import org.meshtastic.core.ui.util.toPosition
|
||||||
|
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||||
|
import org.meshtastic.feature.node.detail.NodeRequestActions
|
||||||
|
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||||
import org.meshtastic.feature.node.model.MetricsState
|
import org.meshtastic.feature.node.model.MetricsState
|
||||||
import org.meshtastic.feature.node.model.TimeFrame
|
import org.meshtastic.feature.node.model.TimeFrame
|
||||||
import org.meshtastic.proto.ConfigProtos.Config
|
import org.meshtastic.proto.ConfigProtos.Config
|
||||||
|
|
@ -90,6 +95,7 @@ constructor(
|
||||||
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
|
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
|
||||||
private val deviceHardwareRepository: DeviceHardwareRepository,
|
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||||
private val firmwareReleaseRepository: FirmwareReleaseRepository,
|
private val firmwareReleaseRepository: FirmwareReleaseRepository,
|
||||||
|
private val nodeRequestActions: NodeRequestActions,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private var destNum: Int? =
|
private var destNum: Int? =
|
||||||
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
|
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
|
||||||
|
|
@ -195,6 +201,34 @@ constructor(
|
||||||
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
|
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
|
||||||
val timeFrame: StateFlow<TimeFrame> = _timeFrame
|
val timeFrame: StateFlow<TimeFrame> = _timeFrame
|
||||||
|
|
||||||
|
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
|
||||||
|
|
||||||
|
val lastTraceRouteTime: StateFlow<Long?> =
|
||||||
|
nodeRequestActions.lastTracerouteTimes.map { it[destNum] }.stateInWhileSubscribed(null)
|
||||||
|
|
||||||
|
val lastRequestNeighborsTime: StateFlow<Long?> =
|
||||||
|
nodeRequestActions.lastRequestNeighborTimes.map { it[destNum] }.stateInWhileSubscribed(null)
|
||||||
|
|
||||||
|
fun requestUserInfo() {
|
||||||
|
destNum?.let { nodeRequestActions.requestUserInfo(viewModelScope, it, state.value.node?.user?.longName ?: "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestPosition() {
|
||||||
|
destNum?.let { nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.longName ?: "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestTelemetry(type: TelemetryType) {
|
||||||
|
destNum?.let {
|
||||||
|
nodeRequestActions.requestTelemetry(viewModelScope, it, state.value.node?.user?.longName ?: "", type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestTraceroute() {
|
||||||
|
destNum?.let {
|
||||||
|
nodeRequestActions.requestTraceroute(viewModelScope, it, state.value.node?.user?.longName ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
initializeFlows()
|
initializeFlows()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.metrics
|
package org.meshtastic.feature.node.metrics
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
|
@ -34,10 +33,17 @@ import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -53,9 +59,11 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.meshtastic.core.strings.getString
|
||||||
import org.jetbrains.compose.resources.StringResource
|
import org.jetbrains.compose.resources.StringResource
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.meshtastic.core.database.entity.MeshLog
|
import org.meshtastic.core.database.entity.MeshLog
|
||||||
|
import org.meshtastic.core.model.TelemetryType
|
||||||
import org.meshtastic.core.model.util.formatUptime
|
import org.meshtastic.core.model.util.formatUptime
|
||||||
import org.meshtastic.core.strings.Res
|
import org.meshtastic.core.strings.Res
|
||||||
import org.meshtastic.core.strings.ble_devices
|
import org.meshtastic.core.strings.ble_devices
|
||||||
|
|
@ -66,6 +74,7 @@ import org.meshtastic.core.strings.wifi_devices
|
||||||
import org.meshtastic.core.ui.component.MainAppBar
|
import org.meshtastic.core.ui.component.MainAppBar
|
||||||
import org.meshtastic.core.ui.component.OptionLabel
|
import org.meshtastic.core.ui.component.OptionLabel
|
||||||
import org.meshtastic.core.ui.component.SlidingSelector
|
import org.meshtastic.core.ui.component.SlidingSelector
|
||||||
|
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||||
import org.meshtastic.feature.node.model.TimeFrame
|
import org.meshtastic.feature.node.model.TimeFrame
|
||||||
import org.meshtastic.proto.PaxcountProtos
|
import org.meshtastic.proto.PaxcountProtos
|
||||||
import org.meshtastic.proto.Portnums.PortNum
|
import org.meshtastic.proto.Portnums.PortNum
|
||||||
|
|
@ -162,6 +171,19 @@ private fun PaxMetricsChart(
|
||||||
@Suppress("MagicNumber", "LongMethod")
|
@Suppress("MagicNumber", "LongMethod")
|
||||||
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||||
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
metricsViewModel.effects.collect { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is NodeRequestEffect.ShowFeedback -> {
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val dateFormat = DateFormat.getDateTimeInstance()
|
val dateFormat = DateFormat.getDateTimeInstance()
|
||||||
var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
|
var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
|
||||||
// Only show logs that can be decoded as PaxcountProtos.Paxcount
|
// Only show logs that can be decoded as PaxcountProtos.Paxcount
|
||||||
|
|
@ -204,10 +226,17 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
|
||||||
showNodeChip = false,
|
showNodeChip = false,
|
||||||
canNavigateUp = true,
|
canNavigateUp = true,
|
||||||
onNavigateUp = onNavigateUp,
|
onNavigateUp = onNavigateUp,
|
||||||
actions = {},
|
actions = {
|
||||||
|
if (!state.isLocal) {
|
||||||
|
IconButton(onClick = { metricsViewModel.requestTelemetry(TelemetryType.PAX) }) {
|
||||||
|
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onClickChip = {},
|
onClickChip = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||||
// Time frame selector
|
// Time frame selector
|
||||||
|
|
|
||||||
|
|
@ -36,18 +36,24 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -59,6 +65,7 @@ import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.meshtastic.core.strings.getString
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.meshtastic.core.model.util.metersIn
|
import org.meshtastic.core.model.util.metersIn
|
||||||
import org.meshtastic.core.model.util.toString
|
import org.meshtastic.core.model.util.toString
|
||||||
|
|
@ -75,6 +82,7 @@ import org.meshtastic.core.strings.timestamp
|
||||||
import org.meshtastic.core.ui.component.MainAppBar
|
import org.meshtastic.core.ui.component.MainAppBar
|
||||||
import org.meshtastic.core.ui.theme.AppTheme
|
import org.meshtastic.core.ui.theme.AppTheme
|
||||||
import org.meshtastic.core.ui.util.formatPositionTime
|
import org.meshtastic.core.ui.util.formatPositionTime
|
||||||
|
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||||
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
||||||
import org.meshtastic.proto.MeshProtos
|
import org.meshtastic.proto.MeshProtos
|
||||||
|
|
||||||
|
|
@ -162,9 +170,22 @@ private fun ActionButtons(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
@Composable
|
@Composable
|
||||||
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.effects.collect { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is NodeRequestEffect.ShowFeedback -> {
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val exportPositionLauncher =
|
val exportPositionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
|
@ -183,10 +204,17 @@ fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateU
|
||||||
showNodeChip = false,
|
showNodeChip = false,
|
||||||
canNavigateUp = true,
|
canNavigateUp = true,
|
||||||
onNavigateUp = onNavigateUp,
|
onNavigateUp = onNavigateUp,
|
||||||
actions = {},
|
actions = {
|
||||||
|
if (!state.isLocal) {
|
||||||
|
IconButton(onClick = { viewModel.requestPosition() }) {
|
||||||
|
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onClickChip = {},
|
onClickChip = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
|
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
|
||||||
val compactWidth = maxWidth < 600.dp
|
val compactWidth = maxWidth < 600.dp
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.metrics
|
package org.meshtastic.feature.node.metrics
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
|
@ -34,12 +33,18 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -58,8 +63,10 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.meshtastic.core.strings.getString
|
||||||
import org.jetbrains.compose.resources.StringResource
|
import org.jetbrains.compose.resources.StringResource
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.meshtastic.core.model.TelemetryType
|
||||||
import org.meshtastic.core.strings.Res
|
import org.meshtastic.core.strings.Res
|
||||||
import org.meshtastic.core.strings.channel_1
|
import org.meshtastic.core.strings.channel_1
|
||||||
import org.meshtastic.core.strings.channel_2
|
import org.meshtastic.core.strings.channel_2
|
||||||
|
|
@ -71,6 +78,7 @@ import org.meshtastic.core.ui.component.OptionLabel
|
||||||
import org.meshtastic.core.ui.component.SlidingSelector
|
import org.meshtastic.core.ui.component.SlidingSelector
|
||||||
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
|
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
|
||||||
import org.meshtastic.core.ui.theme.GraphColors.Red
|
import org.meshtastic.core.ui.theme.GraphColors.Red
|
||||||
|
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||||
import org.meshtastic.feature.node.metrics.GraphUtil.createPath
|
import org.meshtastic.feature.node.metrics.GraphUtil.createPath
|
||||||
|
|
@ -121,12 +129,26 @@ private val LEGEND_DATA =
|
||||||
LegendData(nameRes = Res.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null),
|
LegendData(nameRes = Res.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
@Composable
|
@Composable
|
||||||
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||||
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
|
|
||||||
val data = state.powerMetricsFiltered(selectedTimeFrame)
|
val data = state.powerMetricsFiltered(selectedTimeFrame)
|
||||||
|
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.effects.collect { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is NodeRequestEffect.ShowFeedback -> {
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
MainAppBar(
|
MainAppBar(
|
||||||
|
|
@ -135,10 +157,20 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
|
||||||
showNodeChip = false,
|
showNodeChip = false,
|
||||||
canNavigateUp = true,
|
canNavigateUp = true,
|
||||||
onNavigateUp = onNavigateUp,
|
onNavigateUp = onNavigateUp,
|
||||||
actions = {},
|
actions = {
|
||||||
|
if (!state.isLocal) {
|
||||||
|
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.POWER) }) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onClickChip = {},
|
onClickChip = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(modifier = Modifier.padding(innerPadding)) {
|
Column(modifier = Modifier.padding(innerPadding)) {
|
||||||
PowerMetricsChart(
|
PowerMetricsChart(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.meshtastic.feature.node.metrics
|
package org.meshtastic.feature.node.metrics
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
|
@ -35,12 +34,18 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -56,7 +61,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.meshtastic.core.strings.getString
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.meshtastic.core.model.TelemetryType
|
||||||
import org.meshtastic.core.strings.Res
|
import org.meshtastic.core.strings.Res
|
||||||
import org.meshtastic.core.strings.rssi
|
import org.meshtastic.core.strings.rssi
|
||||||
import org.meshtastic.core.strings.rssi_definition
|
import org.meshtastic.core.strings.rssi_definition
|
||||||
|
|
@ -67,6 +74,7 @@ import org.meshtastic.core.ui.component.MainAppBar
|
||||||
import org.meshtastic.core.ui.component.OptionLabel
|
import org.meshtastic.core.ui.component.OptionLabel
|
||||||
import org.meshtastic.core.ui.component.SlidingSelector
|
import org.meshtastic.core.ui.component.SlidingSelector
|
||||||
import org.meshtastic.core.ui.component.SnrAndRssi
|
import org.meshtastic.core.ui.component.SnrAndRssi
|
||||||
|
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||||
import org.meshtastic.feature.node.metrics.GraphUtil.plotPoint
|
import org.meshtastic.feature.node.metrics.GraphUtil.plotPoint
|
||||||
|
|
@ -93,13 +101,26 @@ private val LEGEND_DATA =
|
||||||
LegendData(nameRes = Res.string.snr, color = Metric.SNR.color, environmentMetric = null),
|
LegendData(nameRes = Res.string.snr, color = Metric.SNR.color, environmentMetric = null),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
@Composable
|
@Composable
|
||||||
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||||
val data = state.signalMetricsFiltered(selectedTimeFrame)
|
val data = state.signalMetricsFiltered(selectedTimeFrame)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.effects.collect { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is NodeRequestEffect.ShowFeedback -> {
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
MainAppBar(
|
MainAppBar(
|
||||||
|
|
@ -108,10 +129,20 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
||||||
showNodeChip = false,
|
showNodeChip = false,
|
||||||
canNavigateUp = true,
|
canNavigateUp = true,
|
||||||
onNavigateUp = onNavigateUp,
|
onNavigateUp = onNavigateUp,
|
||||||
actions = {},
|
actions = {
|
||||||
|
if (!state.isLocal) {
|
||||||
|
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) }) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onClickChip = {},
|
onClickChip = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(modifier = Modifier.padding(innerPadding)) {
|
Column(modifier = Modifier.padding(innerPadding)) {
|
||||||
if (displayInfoDialog) {
|
if (displayInfoDialog) {
|
||||||
|
|
|
||||||
|
|
@ -37,14 +37,19 @@ import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Group
|
import androidx.compose.material.icons.filled.Group
|
||||||
import androidx.compose.material.icons.filled.Groups
|
import androidx.compose.material.icons.filled.Groups
|
||||||
import androidx.compose.material.icons.filled.PersonOff
|
import androidx.compose.material.icons.filled.PersonOff
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
|
@ -92,6 +97,8 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||||
|
import org.meshtastic.feature.node.component.CooldownIconButton
|
||||||
|
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||||
import org.meshtastic.proto.MeshProtos
|
import org.meshtastic.proto.MeshProtos
|
||||||
|
|
||||||
|
|
@ -103,7 +110,7 @@ private data class TracerouteDialog(
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Suppress("LongMethod")
|
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||||
@Composable
|
@Composable
|
||||||
fun TracerouteLogScreen(
|
fun TracerouteLogScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -112,6 +119,18 @@ fun TracerouteLogScreen(
|
||||||
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> },
|
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.effects.collect { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is NodeRequestEffect.ShowFeedback -> {
|
||||||
|
@Suppress("SpreadOperator")
|
||||||
|
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
|
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
|
||||||
|
|
||||||
|
|
@ -131,16 +150,27 @@ fun TracerouteLogScreen(
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState()
|
||||||
MainAppBar(
|
MainAppBar(
|
||||||
title = state.node?.user?.longName ?: "",
|
title = state.node?.user?.longName ?: "",
|
||||||
ourNode = null,
|
ourNode = null,
|
||||||
showNodeChip = false,
|
showNodeChip = false,
|
||||||
canNavigateUp = true,
|
canNavigateUp = true,
|
||||||
onNavigateUp = onNavigateUp,
|
onNavigateUp = onNavigateUp,
|
||||||
actions = {},
|
actions = {
|
||||||
|
if (!state.isLocal) {
|
||||||
|
CooldownIconButton(
|
||||||
|
onClick = { viewModel.requestTraceroute() },
|
||||||
|
cooldownTimestamp = lastTracerouteTime,
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Default.Refresh, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onClickChip = {},
|
onClickChip = {},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize().padding(innerPadding),
|
modifier = modifier.fillMaxSize().padding(innerPadding),
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ dd-sdk-android = "3.5.0"
|
||||||
detekt = "1.23.8"
|
detekt = "1.23.8"
|
||||||
devtools-ksp = "2.3.4"
|
devtools-ksp = "2.3.4"
|
||||||
markdownRenderer = "0.39.1"
|
markdownRenderer = "0.39.1"
|
||||||
|
molecule = "2.0.0"
|
||||||
osmdroid-android = "6.1.20"
|
osmdroid-android = "6.1.20"
|
||||||
protobuf = "4.33.4"
|
protobuf = "4.33.4"
|
||||||
|
|
||||||
|
|
@ -109,6 +110,7 @@ location-services = { module = "com.google.android.gms:play-services-location",
|
||||||
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }
|
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }
|
||||||
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" }
|
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" }
|
||||||
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
|
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
|
||||||
|
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
|
||||||
play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
|
play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
|
||||||
protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" }
|
protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" }
|
||||||
protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" }
|
protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue