From b996415ca9b97e1fd36ffbf9d812030d2dab6be2 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:11:29 -0600 Subject: [PATCH] feat: Add ability to request telemetry from a remote node (#4059) --- .../geeksville/mesh/service/MeshService.kt | 31 +++ .../meshtastic/core/model/TelemetryType.kt | 26 +++ .../meshtastic/core/service/IMeshService.aidl | 5 +- .../composeResources/values/strings.xml | 13 +- .../node/component/AdministrationSection.kt | 183 ++++++++++----- .../feature/node/component/CooldownButton.kt | 83 +++++++ .../feature/node/component/DeviceActions.kt | 153 ++++++++++--- .../node/component/DeviceDetailsSection.kt | 121 ++++++---- .../feature/node/component/MetricsSection.kt | 86 +++++-- .../node/component/NodeDetailsSection.kt | 216 ++++++++++-------- .../feature/node/component/NodeMenu.kt | 3 + .../feature/node/component/NotesSection.kt | 78 ++++--- .../feature/node/component/PositionSection.kt | 187 ++++++++++----- .../node/component/RemoteDeviceActions.kt | 140 +++++++++--- .../feature/node/detail/NodeDetailList.kt | 14 +- .../node/detail/NodeDetailViewModel.kt | 12 + 16 files changed, 991 insertions(+), 360 deletions(-) create mode 100644 core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index b81c83811..f533104b5 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -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 + }, + ) + } + } } } diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt new file mode 100644 index 000000000..41d3136e1 --- /dev/null +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt @@ -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 . + */ + +package org.meshtastic.core.model + +enum class TelemetryType { + DEVICE, + ENVIRONMENT, + AIR_QUALITY, + POWER, + LOCAL_STATS, +} diff --git a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl index 69116184c..0015176e8 100644 --- a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl @@ -179,4 +179,7 @@ interface IMeshService { /// Request device connection status from the radio void getDeviceConnectionStatus(in int requestId, in int destNum); -} \ No newline at end of file + + /// Send request for telemetry to nodeNum + void requestTelemetry(in int requestId, in int destNum, in int type); +} diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 640cc4779..b3bfd814d 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -300,6 +300,7 @@ Instantly send Show quick chat menu Hide quick chat menu + Show quick chat Factory reset Bluetooth is disabled. Please enable it in your device settings. Open settings @@ -351,6 +352,7 @@ Not muted Muted for %1d days, %.1f hours Muted for %.1f hours + Mute status Replace Scan WiFi QR code Invalid WiFi Credential QR code format @@ -742,8 +744,15 @@ Warning: This contact is known, importing will overwrite the previous contact information. Public Key Changed Import - Request NeighborInfo (2.7.15+) - Request Metadata + Request + NeighborInfo (2.7.15+) + Request Telemetry + Device Metrics + Environment Metrics + Air-Quality Metrics + Power Metrics + Local Stats + Metadata Actions Firmware Use 12h clock format diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt index b26eac004..0b961ff75 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt @@ -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, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt index 5fec486b9..ebac19807 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownButton.kt @@ -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 = {}) } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt index 6293f3bb0..5cdf1062f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt @@ -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, ) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt index 575427c83..786530be2 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt @@ -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" diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt index 43a828840..f83d8621d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/MetricsSection.kt @@ -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, 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, 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, + ) +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index ec98cac15..1b1fe312f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -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, - ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt index ebdae2f34..2d3532de1 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt @@ -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() diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt index 0c991ad83..48cbae124 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt @@ -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() - }, - ), - ) + ), + ) + } } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index 4ff9fadd8..7ed357da5 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -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) } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt index f43240a60..12e8e6f22 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt @@ -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))) }, - ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt index c88bc233c..04e8c896d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt @@ -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, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 80806ed1b..a9a28055b 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -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 {