mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 10:11:48 -04:00
feat: Add ability to request telemetry from a remote node (#4059)
This commit is contained in:
@@ -81,6 +81,7 @@ import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.NodeInfo
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getFullTracerouteResponse
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
@@ -2926,5 +2927,35 @@ class MeshService : Service() {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) = toRemoteExceptions {
|
||||
if (destNum != myNodeNum) {
|
||||
val telemetryRequest = telemetry {
|
||||
when (type) {
|
||||
TelemetryType.ENVIRONMENT.ordinal ->
|
||||
environmentMetrics = TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
|
||||
TelemetryType.AIR_QUALITY.ordinal ->
|
||||
airQualityMetrics = TelemetryProtos.AirQualityMetrics.getDefaultInstance()
|
||||
TelemetryType.POWER.ordinal ->
|
||||
powerMetrics = TelemetryProtos.PowerMetrics.getDefaultInstance()
|
||||
TelemetryType.LOCAL_STATS.ordinal ->
|
||||
localStats = TelemetryProtos.LocalStats.getDefaultInstance()
|
||||
TelemetryType.DEVICE.ordinal ->
|
||||
deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
|
||||
else -> deviceMetrics = TelemetryProtos.DeviceMetrics.getDefaultInstance()
|
||||
}
|
||||
}
|
||||
packetHandler.sendToRadio(
|
||||
newMeshPacketTo(destNum).buildMeshPacket(
|
||||
id = requestId,
|
||||
channel = nodeDBbyNodeNum[destNum]?.channel ?: 0,
|
||||
) {
|
||||
portnumValue = Portnums.PortNum.TELEMETRY_APP_VALUE
|
||||
payload = telemetryRequest.toByteString()
|
||||
wantResponse = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.core.model
|
||||
|
||||
enum class TelemetryType {
|
||||
DEVICE,
|
||||
ENVIRONMENT,
|
||||
AIR_QUALITY,
|
||||
POWER,
|
||||
LOCAL_STATS,
|
||||
}
|
||||
@@ -179,4 +179,7 @@ interface IMeshService {
|
||||
|
||||
/// Request device connection status from the radio
|
||||
void getDeviceConnectionStatus(in int requestId, in int destNum);
|
||||
}
|
||||
|
||||
/// Send request for telemetry to nodeNum
|
||||
void requestTelemetry(in int requestId, in int destNum, in int type);
|
||||
}
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
<string name="quick_chat_instant">Instantly send</string>
|
||||
<string name="quick_chat_show">Show quick chat menu</string>
|
||||
<string name="quick_chat_hide">Hide quick chat menu</string>
|
||||
<string name="quick_chat_show_label">Show quick chat</string>
|
||||
<string name="factory_reset">Factory reset</string>
|
||||
<string name="bluetooth_disabled">Bluetooth is disabled. Please enable it in your device settings.</string>
|
||||
<string name="open_settings">Open settings</string>
|
||||
@@ -351,6 +352,7 @@
|
||||
<string name="mute_status_unmuted">Not muted</string>
|
||||
<string name="mute_status_muted_for_days">Muted for %1d days, %.1f hours</string>
|
||||
<string name="mute_status_muted_for_hours">Muted for %.1f hours</string>
|
||||
<string name="mute_status_label">Mute status</string>
|
||||
<string name="replace">Replace</string>
|
||||
<string name="wifi_qr_code_scan">Scan WiFi QR code</string>
|
||||
<string name="wifi_qr_code_error">Invalid WiFi Credential QR code format</string>
|
||||
@@ -742,8 +744,15 @@
|
||||
<string name="import_known_shared_contact_text">Warning: This contact is known, importing will overwrite the previous contact information.</string>
|
||||
<string name="public_key_changed">Public Key Changed</string>
|
||||
<string name="import_label">Import</string>
|
||||
<string name="request_neighbor_info">Request NeighborInfo (2.7.15+)</string>
|
||||
<string name="request_metadata">Request Metadata</string>
|
||||
<string name="request">Request</string>
|
||||
<string name="request_neighbor_info">NeighborInfo (2.7.15+)</string>
|
||||
<string name="request_telemetry">Request Telemetry</string>
|
||||
<string name="request_device_metrics">Device Metrics</string>
|
||||
<string name="request_environment_metrics">Environment Metrics</string>
|
||||
<string name="request_air_quality_metrics">Air-Quality Metrics</string>
|
||||
<string name="request_power_metrics">Power Metrics</string>
|
||||
<string name="request_local_stats">Local Stats</string>
|
||||
<string name="request_metadata">Metadata</string>
|
||||
<string name="actions">Actions</string>
|
||||
<string name="firmware">Firmware</string>
|
||||
<string name="use_12h_format">Use 12h clock format</string>
|
||||
|
||||
@@ -17,15 +17,23 @@
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ForkLeft
|
||||
import androidx.compose.material.icons.filled.Icecream
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.asDeviceVersion
|
||||
@@ -44,7 +52,6 @@ import org.meshtastic.core.strings.remote_admin
|
||||
import org.meshtastic.core.strings.request_metadata
|
||||
import org.meshtastic.core.ui.component.InsetDivider
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
@@ -53,7 +60,6 @@ import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun AdministrationSection(
|
||||
node: Node,
|
||||
@@ -62,31 +68,77 @@ fun AdministrationSection(
|
||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TitledCard(stringResource(Res.string.administration), modifier = modifier) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.request_metadata),
|
||||
leadingIcon = Icons.Default.Memory,
|
||||
trailingIcon = null,
|
||||
onClick = { onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num))) },
|
||||
)
|
||||
ElevatedCard(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 16.dp)) {
|
||||
AdministrationHeader()
|
||||
|
||||
InsetDivider()
|
||||
ListItem(
|
||||
text = stringResource(Res.string.request_metadata),
|
||||
leadingIcon = Icons.Default.Memory,
|
||||
trailingIcon = null,
|
||||
onClick = {
|
||||
onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num)))
|
||||
},
|
||||
)
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.remote_admin),
|
||||
leadingIcon = Icons.Default.Settings,
|
||||
enabled = metricsState.isLocal || node.metadata != null,
|
||||
) {
|
||||
onAction(NodeDetailAction.Navigate(SettingsRoutes.Settings(node.num)))
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.remote_admin),
|
||||
leadingIcon = Icons.Default.Settings,
|
||||
enabled = metricsState.isLocal || node.metadata != null,
|
||||
) {
|
||||
onAction(NodeDetailAction.Navigate(SettingsRoutes.Settings(node.num)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val firmwareVersion = node.metadata?.firmwareVersion
|
||||
val firmwareEdition = metricsState.firmwareEdition
|
||||
if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) {
|
||||
TitledCard(stringResource(Res.string.firmware)) {
|
||||
firmwareEdition?.let {
|
||||
FirmwareSection(metricsState, firmwareEdition, firmwareVersion, onFirmwareSelect)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdministrationHeader() {
|
||||
Text(
|
||||
text = stringResource(Res.string.administration),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirmwareSection(
|
||||
metricsState: MetricsState,
|
||||
firmwareEdition: MeshProtos.FirmwareEdition?,
|
||||
firmwareVersion: String?,
|
||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 16.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.firmware),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
firmwareEdition?.let { edition ->
|
||||
val icon =
|
||||
when (it) {
|
||||
when (edition) {
|
||||
MeshProtos.FirmwareEdition.VANILLA -> Icons.Default.Icecream
|
||||
else -> Icons.Default.ForkLeft
|
||||
}
|
||||
@@ -94,57 +146,68 @@ fun AdministrationSection(
|
||||
ListItem(
|
||||
text = stringResource(Res.string.firmware_edition),
|
||||
leadingIcon = icon,
|
||||
supportingText = it.name,
|
||||
supportingText = edition.name,
|
||||
copyable = true,
|
||||
trailingIcon = null,
|
||||
)
|
||||
}
|
||||
firmwareVersion?.let { firmwareVersion ->
|
||||
val latestStable = metricsState.latestStableFirmware
|
||||
val latestAlpha = metricsState.latestAlphaFirmware
|
||||
|
||||
val deviceVersion = DeviceVersion(firmwareVersion.substringBeforeLast("."))
|
||||
val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.installed_firmware_version),
|
||||
leadingIcon = Icons.Default.Memory,
|
||||
supportingText = firmwareVersion.substringBeforeLast("."),
|
||||
copyable = true,
|
||||
leadingIconTint = statusColor,
|
||||
trailingIcon = null,
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.latest_stable_firmware),
|
||||
leadingIcon = Icons.Default.Memory,
|
||||
supportingText = latestStable.id.substringBeforeLast(".").replace("v", ""),
|
||||
copyable = true,
|
||||
leadingIconTint = MaterialTheme.colorScheme.StatusGreen,
|
||||
trailingIcon = null,
|
||||
onClick = { onFirmwareSelect(latestStable) },
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.latest_alpha_firmware),
|
||||
leadingIcon = Icons.Default.Memory,
|
||||
supportingText = latestAlpha.id.substringBeforeLast(".").replace("v", ""),
|
||||
copyable = true,
|
||||
leadingIconTint = MaterialTheme.colorScheme.StatusYellow,
|
||||
trailingIcon = null,
|
||||
onClick = { onFirmwareSelect(latestAlpha) },
|
||||
)
|
||||
firmwareVersion?.let { version ->
|
||||
FirmwareVersionItems(metricsState, version, firmwareEdition != null, onFirmwareSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirmwareVersionItems(
|
||||
metricsState: MetricsState,
|
||||
version: String,
|
||||
hasEdition: Boolean,
|
||||
onFirmwareSelect: (FirmwareRelease) -> Unit,
|
||||
) {
|
||||
val latestStable = metricsState.latestStableFirmware
|
||||
val latestAlpha = metricsState.latestAlphaFirmware
|
||||
|
||||
val deviceVersion = DeviceVersion(version.substringBeforeLast("."))
|
||||
val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha)
|
||||
|
||||
if (hasEdition) InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.installed_firmware_version),
|
||||
leadingIcon = Icons.Default.Memory,
|
||||
supportingText = version.substringBeforeLast("."),
|
||||
copyable = true,
|
||||
leadingIconTint = statusColor,
|
||||
trailingIcon = null,
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.latest_stable_firmware),
|
||||
leadingIcon = Icons.Default.Memory,
|
||||
supportingText = latestStable.id.substringBeforeLast(".").replace("v", ""),
|
||||
copyable = true,
|
||||
leadingIconTint = MaterialTheme.colorScheme.StatusGreen,
|
||||
trailingIcon = null,
|
||||
onClick = { onFirmwareSelect(latestStable) },
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.latest_alpha_firmware),
|
||||
leadingIcon = Icons.Default.Memory,
|
||||
supportingText = latestAlpha.id.substringBeforeLast(".").replace("v", ""),
|
||||
copyable = true,
|
||||
leadingIconTint = MaterialTheme.colorScheme.StatusYellow,
|
||||
trailingIcon = null,
|
||||
onClick = { onFirmwareSelect(latestAlpha) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceVersion.determineFirmwareStatusColor(
|
||||
latestStable: FirmwareRelease,
|
||||
|
||||
@@ -23,8 +23,11 @@ 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
|
||||
@@ -68,6 +71,30 @@ fun TracerouteButton(
|
||||
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),
|
||||
@@ -91,6 +118,30 @@ fun RequestNeighborsButton(
|
||||
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) {
|
||||
@@ -120,8 +171,40 @@ private fun CooldownButton(text: String, leadingIcon: ImageVector, progress: Flo
|
||||
)
|
||||
}
|
||||
|
||||
@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 = {}) }
|
||||
}
|
||||
|
||||
@@ -17,34 +17,55 @@
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Message
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.automirrored.outlined.VolumeMute
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.QrCode2
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.actions
|
||||
import org.meshtastic.core.strings.direct_message
|
||||
import org.meshtastic.core.strings.favorite
|
||||
import org.meshtastic.core.strings.ignore
|
||||
import org.meshtastic.core.strings.remove
|
||||
import org.meshtastic.core.strings.share_contact
|
||||
import org.meshtastic.core.ui.component.InsetDivider
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.SwitchListItem
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
|
||||
|
||||
@Composable
|
||||
fun DeviceActions(
|
||||
@@ -73,50 +94,124 @@ fun DeviceActions(
|
||||
onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
|
||||
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
|
||||
)
|
||||
TitledCard(title = stringResource(Res.string.actions), modifier = modifier) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.share_contact),
|
||||
leadingIcon = Icons.Rounded.QrCode2,
|
||||
trailingIcon = null,
|
||||
onClick = { onAction(NodeDetailAction.ShareContact) },
|
||||
)
|
||||
if (!isLocal) {
|
||||
InsetDivider()
|
||||
RemoteDeviceActions(
|
||||
|
||||
ElevatedCard(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 12.dp)) {
|
||||
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),
|
||||
)
|
||||
|
||||
PrimaryActionsRow(node, isLocal, onAction, onFavoriteClick = { displayFavoriteDialog = true })
|
||||
|
||||
if (!isLocal) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
|
||||
RemoteDeviceActions(
|
||||
node = node,
|
||||
lastTracerouteTime = lastTracerouteTime,
|
||||
lastRequestNeighborsTime = lastRequestNeighborsTime,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
|
||||
ManagementActions(
|
||||
node = node,
|
||||
lastTracerouteTime = lastTracerouteTime,
|
||||
lastRequestNeighborsTime = lastRequestNeighborsTime,
|
||||
onAction = onAction,
|
||||
onIgnoreClick = { displayIgnoreDialog = true },
|
||||
onRemoveClick = { displayRemoveDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InsetDivider()
|
||||
@Composable
|
||||
private fun PrimaryActionsRow(
|
||||
node: Node,
|
||||
isLocal: Boolean,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
onFavoriteClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (!node.isEffectivelyUnmessageable && !isLocal) {
|
||||
Button(
|
||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Message, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.direct_message))
|
||||
}
|
||||
}
|
||||
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.favorite),
|
||||
leadingIcon = if (node.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
|
||||
leadingIconTint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
|
||||
checked = node.isFavorite,
|
||||
onClick = { displayFavoriteDialog = true },
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { onAction(NodeDetailAction.ShareContact) },
|
||||
modifier = if (node.isEffectivelyUnmessageable || isLocal) Modifier.weight(1f) else Modifier,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Icon(Icons.Rounded.QrCode2, contentDescription = null)
|
||||
if (node.isEffectivelyUnmessageable || isLocal) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.share_contact))
|
||||
}
|
||||
}
|
||||
|
||||
InsetDivider()
|
||||
IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
|
||||
Icon(
|
||||
imageVector = if (node.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
|
||||
contentDescription = stringResource(Res.string.favorite),
|
||||
tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManagementActions(node: Node, onIgnoreClick: () -> Unit, onRemoveClick: () -> Unit) {
|
||||
Column {
|
||||
SwitchListItem(
|
||||
text = stringResource(Res.string.ignore),
|
||||
leadingIcon =
|
||||
if (node.isIgnored) Icons.AutoMirrored.Outlined.VolumeMute else Icons.AutoMirrored.Default.VolumeUp,
|
||||
if (node.isIgnored) {
|
||||
Icons.AutoMirrored.Outlined.VolumeMute
|
||||
} else {
|
||||
Icons.AutoMirrored.Default.VolumeUp
|
||||
},
|
||||
checked = node.isIgnored,
|
||||
onClick = { displayIgnoreDialog = true },
|
||||
onClick = onIgnoreClick,
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.remove),
|
||||
leadingIcon = Icons.Rounded.Delete,
|
||||
trailingIcon = null,
|
||||
onClick = { displayRemoveDialog = true },
|
||||
textColor = MaterialTheme.colorScheme.error,
|
||||
leadingIconTint = MaterialTheme.colorScheme.error,
|
||||
onClick = onRemoveClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -28,7 +30,11 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Router
|
||||
import androidx.compose.material.icons.twotone.Verified
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -39,12 +45,12 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.device
|
||||
import org.meshtastic.core.strings.hardware
|
||||
@@ -52,7 +58,6 @@ import org.meshtastic.core.strings.supported
|
||||
import org.meshtastic.core.strings.supported_by_community
|
||||
import org.meshtastic.core.ui.component.InsetDivider
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
@@ -61,55 +66,79 @@ import org.meshtastic.feature.node.model.MetricsState
|
||||
fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
|
||||
val node = state.node ?: return
|
||||
val deviceHardware = state.deviceHardware ?: return
|
||||
val hwModelName = deviceHardware.displayName
|
||||
val isSupported = deviceHardware.activelySupported
|
||||
TitledCard(stringResource(Res.string.device), modifier = modifier) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.align(Alignment.CenterHorizontally)
|
||||
.size(100.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color = Color(node.colors.second).copy(alpha = .5f), shape = CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
|
||||
ElevatedCard(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = colorScheme.surfaceContainerHigh),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 16.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.device),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
DeviceAvatar(node.colors.second.toLong(), deviceHardware)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.hardware),
|
||||
leadingIcon = Icons.Default.Router,
|
||||
supportingText = deviceHardware.displayName,
|
||||
copyable = true,
|
||||
trailingIcon = null,
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
SupportStatusItem(deviceHardware.activelySupported)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.hardware),
|
||||
leadingIcon = Icons.Default.Router,
|
||||
supportingText = hwModelName,
|
||||
copyable = true,
|
||||
trailingIcon = null,
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text =
|
||||
if (isSupported) {
|
||||
stringResource(Res.string.supported)
|
||||
} else {
|
||||
stringResource(Res.string.supported_by_community)
|
||||
},
|
||||
leadingIcon =
|
||||
if (isSupported) {
|
||||
Icons.TwoTone.Verified
|
||||
} else {
|
||||
ImageVector.vectorResource(org.meshtastic.feature.node.R.drawable.unverified)
|
||||
},
|
||||
leadingIconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
|
||||
trailingIcon = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceAvatar(bgColor: Long, deviceHardware: DeviceHardware) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(100.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color = Color(bgColor).copy(alpha = .5f), shape = CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupportStatusItem(isSupported: Boolean) {
|
||||
ListItem(
|
||||
text =
|
||||
if (isSupported) {
|
||||
stringResource(Res.string.supported)
|
||||
} else {
|
||||
stringResource(Res.string.supported_by_community)
|
||||
},
|
||||
leadingIcon =
|
||||
if (isSupported) {
|
||||
Icons.TwoTone.Verified
|
||||
} else {
|
||||
ImageVector.vectorResource(org.meshtastic.feature.node.R.drawable.unverified)
|
||||
},
|
||||
leadingIconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
|
||||
trailingIcon = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifier = Modifier) {
|
||||
val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg"
|
||||
|
||||
@@ -17,10 +17,19 @@
|
||||
|
||||
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
|
||||
@@ -29,42 +38,93 @@ 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.core.ui.component.TitledCard
|
||||
import org.meshtastic.feature.node.model.LogsType
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
|
||||
@Composable
|
||||
@Suppress("MultipleEmitters")
|
||||
fun MetricsSection(
|
||||
node: Node,
|
||||
metricsState: MetricsState,
|
||||
availableLogs: Set<LogsType>,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (node.hasEnvironmentMetrics) {
|
||||
TitledCard(stringResource(Res.string.environment), modifier = modifier) {}
|
||||
EnvironmentMetrics(node, isFahrenheit = metricsState.isFahrenheit, displayUnits = metricsState.displayUnits)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
EnvironmentCard(node, metricsState)
|
||||
}
|
||||
|
||||
if (node.hasPowerMetrics) {
|
||||
TitledCard(stringResource(Res.string.power), modifier = modifier) {}
|
||||
PowerMetrics(node)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
PowerCard(node)
|
||||
}
|
||||
|
||||
val nonPositionLogs = availableLogs.filter { it != LogsType.NODE_MAP && it != LogsType.POSITIONS }
|
||||
|
||||
if (nonPositionLogs.isNotEmpty()) {
|
||||
TitledCard(title = stringResource(Res.string.logs), modifier = modifier) {
|
||||
nonPositionLogs.forEach { type ->
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,9 +17,14 @@
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
@@ -28,16 +33,18 @@ import androidx.compose.material.icons.filled.KeyOff
|
||||
import androidx.compose.material.icons.filled.Numbers
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Work
|
||||
import androidx.compose.material.icons.outlined.NoCell
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.twotone.Person
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -48,123 +55,154 @@ import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.details
|
||||
import org.meshtastic.core.strings.encryption_error
|
||||
import org.meshtastic.core.strings.encryption_error_text
|
||||
import org.meshtastic.core.strings.long_name
|
||||
import org.meshtastic.core.strings.node_number
|
||||
import org.meshtastic.core.strings.node_sort_last_heard
|
||||
import org.meshtastic.core.strings.role
|
||||
import org.meshtastic.core.strings.short_name
|
||||
import org.meshtastic.core.strings.unmonitored_or_infrastructure
|
||||
import org.meshtastic.core.strings.uptime
|
||||
import org.meshtastic.core.strings.user_id
|
||||
import org.meshtastic.core.ui.component.InsetDivider
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
|
||||
|
||||
@Composable
|
||||
fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) {
|
||||
TitledCard(title = stringResource(Res.string.details), modifier = modifier) {
|
||||
if (node.mismatchKey) {
|
||||
ElevatedCard(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MismatchKeyWarning() {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyOff,
|
||||
contentDescription = stringResource(Res.string.encryption_error),
|
||||
tint = Color.Red,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.encryption_error),
|
||||
style = MaterialTheme.typography.titleLarge.copy(color = Color.Red),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.encryption_error_text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
MainNodeDetails(node)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainNodeDetails(node: Node) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.long_name),
|
||||
leadingIcon = Icons.TwoTone.Person,
|
||||
supportingText = node.user.longName.ifEmpty { "???" },
|
||||
copyable = true,
|
||||
trailingIcon = null,
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
InfoItem(
|
||||
label = stringResource(Res.string.short_name),
|
||||
value = node.user.shortName.ifEmpty { "???" },
|
||||
icon = Icons.Default.Person,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
InfoItem(
|
||||
label = stringResource(Res.string.role),
|
||||
value = node.user.role.name,
|
||||
icon = Icons.Default.Work,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
InsetDivider()
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.short_name),
|
||||
leadingIcon = Icons.Outlined.Person,
|
||||
supportingText = node.user.shortName.ifEmpty { "???" },
|
||||
copyable = true,
|
||||
trailingIcon = null,
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
InfoItem(
|
||||
label = stringResource(Res.string.node_sort_last_heard),
|
||||
value = formatAgo(node.lastHeard),
|
||||
icon = Icons.Default.History,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
InfoItem(
|
||||
label = stringResource(Res.string.node_number),
|
||||
value = node.num.toUInt().toString(),
|
||||
icon = Icons.Default.Numbers,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
InsetDivider()
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.node_number),
|
||||
leadingIcon = Icons.Default.Numbers,
|
||||
supportingText = node.num.toUInt().toString(),
|
||||
copyable = true,
|
||||
trailingIcon = null,
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.user_id),
|
||||
leadingIcon = Icons.Default.Person,
|
||||
supportingText = node.user.id,
|
||||
copyable = true,
|
||||
trailingIcon = null,
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.role),
|
||||
leadingIcon = Icons.Default.Work,
|
||||
supportingText = node.user.role.name,
|
||||
trailingIcon = null,
|
||||
)
|
||||
|
||||
if (node.isEffectivelyUnmessageable) {
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.unmonitored_or_infrastructure),
|
||||
leadingIcon = Icons.Outlined.NoCell,
|
||||
trailingIcon = null,
|
||||
)
|
||||
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
|
||||
private fun InfoItem(label: String, value: String, icon: ImageVector, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
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.Medium,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
if (node.deviceMetrics.uptimeSeconds > 0) {
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.uptime),
|
||||
leadingIcon = Icons.Default.CheckCircle,
|
||||
supportingText = formatUptime(node.deviceMetrics.uptimeSeconds),
|
||||
trailingIcon = null,
|
||||
)
|
||||
}
|
||||
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.node_sort_last_heard),
|
||||
leadingIcon = Icons.Default.History,
|
||||
supportingText = formatAgo(node.lastHeard),
|
||||
trailingIcon = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ package org.meshtastic.feature.node.component
|
||||
import androidx.compose.runtime.Composable
|
||||
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.favorite
|
||||
import org.meshtastic.core.strings.favorite_add
|
||||
@@ -100,6 +101,8 @@ sealed class NodeMenuAction {
|
||||
|
||||
data class RequestPosition(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class RequestTelemetry(val node: Node, val type: TelemetryType) : NodeMenuAction()
|
||||
|
||||
data class TraceRoute(val node: Node) : NodeMenuAction()
|
||||
|
||||
data class MoreDetails(val node: Node) : NodeMenuAction()
|
||||
|
||||
@@ -17,14 +17,20 @@
|
||||
|
||||
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.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -34,6 +40,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -42,42 +49,57 @@ import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.add_a_note
|
||||
import org.meshtastic.core.strings.notes
|
||||
import org.meshtastic.core.strings.save
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
|
||||
@Composable
|
||||
fun NotesSection(node: Node, onSaveNotes: (Int, String) -> Unit, modifier: Modifier = Modifier) {
|
||||
if (node.isFavorite) {
|
||||
TitledCard(title = stringResource(Res.string.notes), modifier = modifier) {
|
||||
val originalNotes = node.notes
|
||||
var notes by remember(node.notes) { mutableStateOf(node.notes) }
|
||||
val edited = notes.trim() != originalNotes.trim()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
ElevatedCard(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.notes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
placeholder = { Text(stringResource(Res.string.add_a_note)) },
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val originalNotes = node.notes
|
||||
var notes by remember(node.notes) { mutableStateOf(node.notes) }
|
||||
val edited = notes.trim() != originalNotes.trim()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text(stringResource(Res.string.add_a_note)) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onSaveNotes(node.num, notes.trim())
|
||||
keyboardController?.hide()
|
||||
},
|
||||
enabled = edited,
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Save, contentDescription = stringResource(Res.string.save))
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
onSaveNotes(node.num, notes.trim())
|
||||
keyboardController?.hide()
|
||||
},
|
||||
enabled = edited,
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Save, contentDescription = stringResource(Res.string.save))
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
onSaveNotes(node.num, notes.trim())
|
||||
keyboardController?.hide()
|
||||
},
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,29 +17,49 @@
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Explore
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.SocialDistance
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.util.toDistanceString
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.exchange_position
|
||||
import org.meshtastic.core.strings.node_sort_distance
|
||||
import org.meshtastic.core.strings.open_compass
|
||||
import org.meshtastic.core.strings.position
|
||||
import org.meshtastic.core.ui.component.InsetDivider
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.feature.node.model.LogsType
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
||||
|
||||
/**
|
||||
* Displays node position details, last update time, distance, and related actions like requesting position and
|
||||
@@ -56,63 +76,120 @@ fun PositionSection(
|
||||
) {
|
||||
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits)
|
||||
val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0
|
||||
TitledCard(title = stringResource(Res.string.position), modifier = modifier) {
|
||||
// Current position coordinates (linked)
|
||||
if (hasValidPosition) {
|
||||
InlineMap(node = node, Modifier.fillMaxWidth().height(200.dp))
|
||||
|
||||
LinkedCoordinatesItem(node)
|
||||
}
|
||||
|
||||
// Distance (if available)
|
||||
if (distance != null && distance.isNotEmpty()) {
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.node_sort_distance),
|
||||
leadingIcon = Icons.Default.SocialDistance,
|
||||
supportingText = distance,
|
||||
copyable = true,
|
||||
trailingIcon = null,
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
InsetDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Exchange position action
|
||||
ListItem(
|
||||
text = stringResource(Res.string.exchange_position),
|
||||
leadingIcon = Icons.Default.LocationOn,
|
||||
trailingIcon = null,
|
||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
|
||||
)
|
||||
|
||||
if (hasValidPosition) {
|
||||
InsetDivider()
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.open_compass),
|
||||
leadingIcon = Icons.Default.Explore,
|
||||
trailingIcon = null,
|
||||
onClick = { onAction(NodeDetailAction.OpenCompass(node, metricsState.displayUnits)) },
|
||||
)
|
||||
}
|
||||
|
||||
// Node Map log
|
||||
if (availableLogs.contains(LogsType.NODE_MAP)) {
|
||||
InsetDivider()
|
||||
|
||||
ListItem(text = stringResource(LogsType.NODE_MAP.titleRes), leadingIcon = LogsType.NODE_MAP.icon) {
|
||||
onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num)))
|
||||
if (hasValidPosition) {
|
||||
PositionMap(node, distance)
|
||||
LinkedCoordinatesItem(node)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Positions Log
|
||||
if (availableLogs.contains(LogsType.POSITIONS)) {
|
||||
InsetDivider()
|
||||
PositionActionButtons(node, hasValidPosition, metricsState.displayUnits, onAction)
|
||||
|
||||
ListItem(text = stringResource(LogsType.POSITIONS.titleRes), leadingIcon = LogsType.POSITIONS.icon) {
|
||||
onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num)))
|
||||
if (availableLogs.contains(LogsType.NODE_MAP) || availableLogs.contains(LogsType.POSITIONS)) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (availableLogs.contains(LogsType.NODE_MAP)) {
|
||||
AssistChip(
|
||||
onClick = { onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num))) },
|
||||
label = { Text(stringResource(LogsType.NODE_MAP.titleRes)) },
|
||||
leadingIcon = { Icon(LogsType.NODE_MAP.icon, null, Modifier.size(18.dp)) },
|
||||
)
|
||||
}
|
||||
|
||||
if (availableLogs.contains(LogsType.POSITIONS)) {
|
||||
AssistChip(
|
||||
onClick = {
|
||||
onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num)))
|
||||
},
|
||||
label = { Text(stringResource(LogsType.POSITIONS.titleRes)) },
|
||||
leadingIcon = { Icon(LogsType.POSITIONS.icon, null, Modifier.size(18.dp)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PositionMap(node: Node, distance: String?) {
|
||||
Box(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(200.dp)) {
|
||||
InlineMap(node = node, Modifier.fillMaxSize())
|
||||
}
|
||||
if (distance != null && distance.isNotEmpty()) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(12.dp).align(Alignment.TopEnd),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.SocialDistance, null, Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(distance, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PositionActionButtons(
|
||||
node: Node,
|
||||
hasValidPosition: Boolean,
|
||||
displayUnits: DisplayUnits,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Default.LocationOn, null, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(text = stringResource(Res.string.exchange_position), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
if (hasValidPosition) {
|
||||
FilledTonalButton(
|
||||
onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Icon(Icons.Default.Explore, null, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(text = stringResource(Res.string.open_compass), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,53 +17,133 @@
|
||||
|
||||
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.automirrored.twotone.Message
|
||||
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.direct_message
|
||||
import org.meshtastic.core.strings.exchange_userinfo
|
||||
import org.meshtastic.core.ui.component.InsetDivider
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
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
|
||||
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
|
||||
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
internal fun RemoteDeviceActions(
|
||||
node: Node,
|
||||
lastTracerouteTime: Long?,
|
||||
lastRequestNeighborsTime: Long?,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
) {
|
||||
if (!node.isEffectivelyUnmessageable) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.direct_message),
|
||||
leadingIcon = Icons.AutoMirrored.TwoTone.Message,
|
||||
trailingIcon = null,
|
||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) },
|
||||
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),
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
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))) },
|
||||
)
|
||||
|
||||
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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(
|
||||
text = stringResource(Res.string.exchange_userinfo),
|
||||
leadingIcon = Icons.Default.Person,
|
||||
trailingIcon = null,
|
||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestUserInfo(node))) },
|
||||
)
|
||||
|
||||
InsetDivider()
|
||||
|
||||
TracerouteButton(
|
||||
lastTracerouteTime = lastTracerouteTime,
|
||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) },
|
||||
)
|
||||
RequestNeighborsButton(
|
||||
lastRequestNeighborsTime = lastRequestNeighborsTime,
|
||||
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestNeighborInfo(node))) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,16 +155,10 @@ fun NodeDetailList(
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp).focusable(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
if (metricsState.deviceHardware != null) {
|
||||
DeviceDetailsSection(metricsState)
|
||||
}
|
||||
|
||||
NodeDetailsSection(node)
|
||||
|
||||
NotesSection(node = node, onSaveNotes = onSaveNotes)
|
||||
|
||||
DeviceActions(
|
||||
isLocal = metricsState.isLocal,
|
||||
lastTracerouteTime = lastTracerouteTime,
|
||||
@@ -191,8 +185,14 @@ fun NodeDetailList(
|
||||
},
|
||||
)
|
||||
|
||||
if (metricsState.deviceHardware != null) {
|
||||
DeviceDetailsSection(metricsState)
|
||||
}
|
||||
|
||||
MetricsSection(node, metricsState, availableLogs, onAction)
|
||||
|
||||
NotesSection(node = node, onSaveNotes = onSaveNotes)
|
||||
|
||||
if (!metricsState.isManaged) {
|
||||
AdministrationSection(
|
||||
node = node,
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||
@@ -62,6 +63,7 @@ constructor(
|
||||
_lastRequestNeighborsTime.value = System.currentTimeMillis()
|
||||
}
|
||||
is NodeMenuAction.RequestPosition -> requestPosition(action.node.num)
|
||||
is NodeMenuAction.RequestTelemetry -> requestTelemetry(action.node.num, action.type)
|
||||
is NodeMenuAction.TraceRoute -> {
|
||||
requestTraceroute(action.node.num)
|
||||
_lastTraceRouteTime.value = System.currentTimeMillis()
|
||||
@@ -136,6 +138,16 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestTelemetry(destNum: Int, type: TelemetryType) {
|
||||
Timber.i("Requesting telemetry for '$destNum'")
|
||||
try {
|
||||
val packetId = serviceRepository.meshService?.packetId ?: return
|
||||
serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal)
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e("Request telemetry error: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestTraceroute(destNum: Int) {
|
||||
Timber.i("Requesting traceroute for '$destNum'")
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user