fix(release): fixes to prep for release (#4546)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-12 14:23:19 -06:00 committed by GitHub
parent c5f2b1bbea
commit 80d9a2e0aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2324 additions and 312 deletions

View file

@ -73,6 +73,7 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(16.dp))
SectionDivider()
val deviceText =
state.reportedTarget?.let { target -> "${deviceHardware.displayName} ($target)" }
?: deviceHardware.displayName

View file

@ -14,6 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
package org.meshtastic.feature.node.component
import android.content.ClipData
@ -30,6 +32,7 @@ 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.automirrored.rounded.Notes
import androidx.compose.material.icons.rounded.Numbers
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -48,6 +51,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@ -69,10 +73,12 @@ import org.meshtastic.core.strings.role
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.short_name
import org.meshtastic.core.strings.snr
import org.meshtastic.core.strings.status_message
import org.meshtastic.core.strings.supported
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.user_id
import org.meshtastic.core.strings.via_mqtt
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.icon.ArrowCircleUp
import org.meshtastic.core.ui.icon.ChannelUtilization
import org.meshtastic.core.ui.icon.Cloud
@ -84,6 +90,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Person
import org.meshtastic.core.ui.icon.Role
import org.meshtastic.core.ui.icon.Verified
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.formatAgo
@Composable
@ -135,13 +142,17 @@ private fun MismatchKeyWarning(modifier: Modifier = Modifier) {
private fun MainNodeDetails(node: Node) {
Column {
NameAndRoleRow(node)
node.nodeStatus?.let { status ->
SectionDivider()
StatusMessageRow(status)
}
SectionDivider()
NodeIdentificationRow(node)
SectionDivider()
HearsAndHopsRow(node)
SectionDivider()
UserAndUptimeRow(node)
if (node.hopsAway == 0) {
if (node.hopsAway == 0 && !node.viaMqtt) {
SectionDivider()
SignalRow(node)
}
@ -175,6 +186,16 @@ private fun NameAndRoleRow(node: Node) {
}
}
@Composable
private fun StatusMessageRow(status: String) {
InfoItem(
label = stringResource(Res.string.status_message),
value = status,
icon = Icons.AutoMirrored.Rounded.Notes,
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
private fun NodeIdentificationRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
@ -352,3 +373,12 @@ private fun PublicKeyItem(publicKeyBytes: ByteArray) {
)
}
}
@PreviewLightDark
@Composable
private fun NodeDetailsSectionPreview() {
AppTheme {
val node = NodePreviewParameterProvider().values.last().copy(nodeStatus = "Going to the farm.. to grow wheat.")
NodeDetailsSection(node = node)
}
}

View file

@ -30,9 +30,12 @@ import androidx.compose.foundation.layout.Spacer
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.rounded.Notes
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
@ -84,6 +87,7 @@ import org.meshtastic.core.ui.component.Snr
import org.meshtastic.core.ui.component.SoilMoistureInfo
import org.meshtastic.core.ui.component.SoilTemperatureInfo
import org.meshtastic.core.ui.component.TemperatureInfo
import org.meshtastic.core.ui.component.TransportIcon
import org.meshtastic.core.ui.component.determineSignalQuality
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.icon.AirUtilization
@ -171,6 +175,28 @@ fun NodeItem(
contentColor = contentColor,
)
thatNode.nodeStatus?.let { status ->
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.Notes,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = contentColor.copy(alpha = 0.7f),
)
Text(
text = status,
style = MaterialTheme.typography.bodyMedium,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
NodeBatteryPositionRow(
thatNode = thatNode,
distance = distance,
@ -252,7 +278,7 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
if (thatNode.hopsAway > 0) {
HopsInfo(hops = thatNode.hopsAway, contentColor = contentColor)
} else {
} else if (thatNode.hopsAway == 0 && !thatNode.viaMqtt) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
@ -395,13 +421,21 @@ private fun NodeItemHeader(
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = longName,
style = MaterialTheme.typography.titleMediumEmphasized.copy(fontStyle = style),
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = longName,
style = MaterialTheme.typography.titleMediumEmphasized.copy(fontStyle = style),
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
TransportIcon(
transport = thatNode.lastTransport,
viaMqtt = thatNode.viaMqtt,
modifier = Modifier.size(16.dp),
)
}
LastHeardInfo(lastHeard = thatNode.lastHeard, showLabel = false, contentColor = contentColor)
}
@ -439,6 +473,17 @@ fun NodeInfoSimplePreview() {
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoStatusPreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode =
NodePreviewParameterProvider().values.last().copy(nodeStatus = "Going to the farm.. to grow wheat.")
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, connectionState = ConnectionState.Connected)
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoSignalPreview() {

View file

@ -1,274 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import android.Manifest
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.AdministrationSection
import org.meshtastic.feature.node.component.CompassSheetContent
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
fun NodeDetailContent(
node: Node,
ourNode: Node?,
metricsState: MetricsState,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
availableLogs: Set<LogsType>,
onAction: (NodeDetailAction) -> Unit,
onSaveNotes: (nodeNum: Int, notes: String) -> Unit,
modifier: Modifier = Modifier,
) {
var showShareDialog by remember { mutableStateOf(false) }
if (showShareDialog) {
SharedContactDialog(node) { showShareDialog = false }
}
NodeDetailList(
node = node,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
ourNode = ourNode,
metricsState = metricsState,
onAction = { action ->
if (action is NodeDetailAction.ShareContact) {
showShareDialog = true
} else {
onAction(action)
}
},
modifier = modifier,
availableLogs = availableLogs,
onSaveNotes = onSaveNotes,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongMethod")
fun NodeDetailList(
node: Node,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
ourNode: Node?,
metricsState: MetricsState,
onAction: (NodeDetailAction) -> Unit,
availableLogs: Set<LogsType>,
onSaveNotes: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
var showFirmwareSheet by remember { mutableStateOf(false) }
var selectedFirmware by remember { mutableStateOf<FirmwareRelease?>(null) }
var showCompassSheet by remember { mutableStateOf(false) }
val inspectionMode = LocalInspectionMode.current
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
val compassUiState by
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
var compassTargetNode by remember { mutableStateOf<Node?>(null) }
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
val locationSettingsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> }
FirmwareSheetHost(
showFirmwareSheet = showFirmwareSheet,
onDismiss = { showFirmwareSheet = false },
firmwareRelease = selectedFirmware,
)
CompassSheetHost(
showCompassSheet = showCompassSheet,
compassViewModel = compassViewModel,
compassUiState = compassUiState,
onDismiss = { showCompassSheet = false },
permissionLauncher = permissionLauncher,
locationSettingsLauncher = locationSettingsLauncher,
onRequestPosition = {
compassTargetNode?.let { target ->
onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(target)))
}
},
)
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp).focusable(),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
NodeDetailsSection(node)
DeviceActions(
isLocal = metricsState.isLocal,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
node = node,
availableLogs = availableLogs,
onAction = onAction,
metricsState = metricsState,
)
PositionSection(
node = node,
ourNode = ourNode,
metricsState = metricsState,
availableLogs = availableLogs,
onAction = { action ->
when (action) {
is NodeDetailAction.OpenCompass -> {
compassViewModel?.start(action.node, action.displayUnits)
compassTargetNode = action.node
showCompassSheet = compassViewModel != null
}
else -> onAction(action)
}
},
)
if (metricsState.deviceHardware != null) {
DeviceDetailsSection(metricsState)
}
NotesSection(node = node, onSaveNotes = onSaveNotes)
if (!metricsState.isManaged) {
AdministrationSection(
node = node,
metricsState = metricsState,
onAction = onAction,
onFirmwareSelect = { firmware ->
selectedFirmware = firmware
showFirmwareSheet = true
},
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FirmwareSheetHost(showFirmwareSheet: Boolean, onDismiss: () -> Unit, firmwareRelease: FirmwareRelease?) {
if (showFirmwareSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
firmwareRelease?.let { FirmwareReleaseSheetContent(firmwareRelease = it) }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongParameterList")
private fun CompassSheetHost(
showCompassSheet: Boolean,
compassViewModel: CompassViewModel?,
compassUiState: CompassUiState,
onDismiss: () -> Unit,
permissionLauncher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>,
locationSettingsLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>,
onRequestPosition: () -> Unit,
) {
if (showCompassSheet && compassViewModel != null) {
DisposableEffect(Unit) { onDispose { compassViewModel.stop() } }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(
onDismissRequest = {
compassViewModel.stop()
onDismiss()
},
sheetState = sheetState,
) {
CompassSheetContent(
uiState = compassUiState,
onRequestLocationPermission = {
permissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
)
},
onOpenLocationSettings = {
locationSettingsLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
},
onRequestPosition = onRequestPosition,
modifier = Modifier.padding(bottom = 24.dp),
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme {
NodeDetailList(
node = node,
ourNode = node,
lastTracerouteTime = null,
lastRequestNeighborsTime = null,
metricsState = MetricsState.Companion.Empty,
availableLogs = emptySet(),
onAction = {},
onSaveNotes = { _, _ -> },
)
}
}

View file

@ -52,6 +52,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -61,9 +63,12 @@ import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.details
import org.meshtastic.core.strings.loading
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.AdministrationSection
@ -75,6 +80,7 @@ import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
private sealed interface NodeDetailOverlay {
@ -143,7 +149,8 @@ private fun NodeDetailScaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = node?.user?.long_name ?: "",
title = getString(Res.string.details),
subtitle = node?.user?.long_name ?: "",
ourNode = uiState.ourNode,
showNodeChip = false,
canNavigateUp = true,
@ -343,3 +350,26 @@ private fun handleNodeAction(
else -> {}
}
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailListPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme {
val uiState =
NodeDetailUiState(
node = node,
ourNode = node,
metricsState = MetricsState(node = node, isLocal = true, isManaged = false),
availableLogs = emptySet(),
)
NodeDetailList(
node = node,
ourNode = node,
uiState = uiState,
listState = rememberLazyListState(),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}

View file

@ -46,6 +46,7 @@ import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
@ -216,7 +217,13 @@ constructor(
deviceMetrics = data.telemetry.filter { it.device_metrics != null },
powerMetrics = data.telemetry.filter { it.power_metrics != null },
hostMetrics = data.telemetry.filter { it.host_metrics != null },
signalMetrics = data.packets.filter { (it.rx_time ?: 0) > 0 },
signalMetrics =
data.packets.filter { pkt ->
(pkt.rx_time ?: 0) > 0 &&
pkt.hop_start == pkt.hop_limit &&
pkt.via_mqtt != true &&
pkt.isLora()
},
positionLogs = data.positionPackets.mapNotNull { it.toPosition() },
paxMetrics = data.paxLogs,
tracerouteRequests = data.tracerouteRequests,