feat: Add ability to request telemetry from a remote node (#4059)

This commit is contained in:
James Rich
2025-12-24 14:11:29 -06:00
committed by GitHub
parent 79fe6416b3
commit b996415ca9
16 changed files with 991 additions and 360 deletions

View File

@@ -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
},
)
}
}
}
}

View File

@@ -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,
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 = {}) }
}

View File

@@ -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,
)
}
}

View File

@@ -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"

View File

@@ -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,
)
}

View File

@@ -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,
)
}

View File

@@ -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()

View File

@@ -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()
},
),
)
),
)
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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))) },
)
}

View File

@@ -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,

View File

@@ -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 {