refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)

This commit is contained in:
James Rich 2026-04-10 15:54:09 -05:00 committed by GitHub
parent 56332f4d77
commit 520fa717a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 3464 additions and 2169 deletions

View file

@ -23,32 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
/** OSMDroid implementation of [MapViewProvider]. */
@Single
class FdroidMapViewProvider : MapViewProvider {
@Composable
override fun MapView(
modifier: Modifier,
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int?,
nodeTracks: List<Any>?,
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
waypointId: Int?,
) {
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
@Suppress("UNCHECKED_CAST")
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
focusedNodeNum = focusedNodeNum,
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}

View file

@ -17,10 +17,8 @@
package org.meshtastic.app.map
import android.Manifest
import android.graphics.Paint
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@ -38,9 +36,11 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -49,6 +49,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -56,8 +57,6 @@ import androidx.compose.runtime.rememberCoroutineScope
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.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@ -79,6 +78,7 @@ import org.meshtastic.app.map.component.CacheLayout
import org.meshtastic.app.map.component.DownloadButton
import org.meshtastic.app.map.component.EditWaypointDialog
import org.meshtastic.app.map.component.MapButton
import org.meshtastic.app.map.component.MapControlsOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled
@ -96,6 +96,7 @@ import org.meshtastic.core.resources.delete_for_everyone
import org.meshtastic.core.resources.delete_for_me
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager
@ -105,7 +106,6 @@ import org.meshtastic.core.resources.map_clear_tiles
import org.meshtastic.core.resources.map_download_complete
import org.meshtastic.core.resources.map_download_errors
import org.meshtastic.core.resources.map_download_region
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_node_popup_details
import org.meshtastic.core.resources.map_offline_manager
import org.meshtastic.core.resources.map_purge_fail
@ -114,10 +114,8 @@ import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.map_subDescription
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.resources.waypoint_delete
import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BasicListItem
@ -126,18 +124,13 @@ import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.LocationDisabled
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.core.ui.icon.Tune
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.Position
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
@ -156,38 +149,23 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.infowindow.InfoWindow
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import java.io.File
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.roundToInt
private fun MapView.updateMarkers(
nodeMarkers: List<MarkerWithLabel>,
waypointMarkers: List<MarkerWithLabel>,
trackMarkers: List<Marker>,
trackPolylines: List<Polyline>,
nodeClusterer: RadiusMarkerClusterer,
) {
Logger.d {
"Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints ${trackMarkers.size} tracks"
}
val trackOverlayIds = (trackMarkers + trackPolylines).toSet()
Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" }
overlays.removeAll { overlay ->
overlay is MarkerWithLabel ||
(overlay is Marker && overlay !in nodeClusterer.items && overlay !in trackOverlayIds) ||
(overlay is Polyline && overlay !in trackOverlayIds)
overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items)
}
overlays.addAll(waypointMarkers)
overlays.addAll(trackPolylines)
overlays.addAll(trackMarkers)
nodeClusterer.items.clear()
nodeClusterer.items.addAll(nodeMarkers)
@ -225,17 +203,12 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int)
* @param navigateToNodeDetails Callback to navigate to the details screen of a selected node.
*/
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
@Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
fun MapView(
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int? = null,
nodeTracks: List<Position>? = null,
tracerouteOverlay: TracerouteOverlay? = null,
tracerouteNodePositions: Map<Int, Position> = emptyMap(),
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
) {
var mapFilterExpanded by remember { mutableStateOf(false) }
@ -334,6 +307,16 @@ fun MapView(
}
}
// Keep screen on while location tracking is active
LaunchedEffect(myLocationOverlay) {
val activity = context as? android.app.Activity ?: return@LaunchedEffect
if (myLocationOverlay != null) {
activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
@ -349,77 +332,21 @@ fun MapView(
}
}
val tracerouteSelection =
remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
mapViewModel.tracerouteNodeSelection(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
nodes = nodes,
)
}
val overlayNodeNums = tracerouteSelection.overlayNodeNums
val nodeLookup = tracerouteSelection.nodeLookup
val nodesForMarkers = tracerouteSelection.nodesForMarkers
val tracerouteForwardPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.forwardRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
val tracerouteReturnPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.returnRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
LaunchedEffect(tracerouteOverlay, nodesForMarkers) {
if (tracerouteOverlay != null) {
onTracerouteMappableCountChanged(nodesForMarkers.size, tracerouteOverlay.relatedNodeNums.size)
}
}
val tracerouteHeadingReferencePoints =
remember(tracerouteForwardPoints, tracerouteReturnPoints) {
when {
tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
else -> emptyList()
}
}
val tracerouteForwardOffsetPoints =
remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
offsetPolyline(
points = tracerouteForwardPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = tracerouteHeadingReferencePoints,
sideMultiplier = 1.0,
)
}
val tracerouteReturnOffsetPoints =
remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
offsetPolyline(
points = tracerouteReturnPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = tracerouteHeadingReferencePoints,
sideMultiplier = -1.0,
)
}
val traceroutePolylines = remember { mutableStateListOf<Polyline>() }
var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
val nodesWithPosition = nodes.filter { it.validPosition != null }
val ourNode = mapViewModel.ourNodeInfo.value
val displayUnits =
mapViewModel.config.display?.units ?: org.meshtastic.proto.Config.DisplayConfig.DisplayUnits.METRIC
val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC
val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
return nodesWithPosition.mapNotNull { node ->
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
return@mapNotNull null
}
if (
mapFilterStateValue.onlyFavorites &&
!node.isFavorite &&
!overlayNodeNums.contains(node.num) &&
!node.equals(ourNode)
mapFilterStateValue.lastHeardFilter.seconds != 0L &&
(nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds &&
node.num != ourNode?.num
) {
return@mapNotNull null
}
@ -580,53 +507,6 @@ fun MapView(
invalidate()
}
fun MapView.updateTracerouteOverlay(forwardPoints: List<GeoPoint>, returnPoints: List<GeoPoint>) {
overlays.removeAll(traceroutePolylines)
traceroutePolylines.clear()
fun buildPolyline(points: List<GeoPoint>, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
setPoints(points)
outlinePaint.apply {
this.color = color
this.strokeWidth = strokeWidth
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
style = Paint.Style.STROKE
}
}
forwardPoints
.takeIf { it.size >= 2 }
?.let { points ->
traceroutePolylines.add(
buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }),
)
}
returnPoints
.takeIf { it.size >= 2 }
?.let { points ->
traceroutePolylines.add(
buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }),
)
}
overlays.addAll(traceroutePolylines)
invalidate()
}
LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
if (allPoints.isNotEmpty()) {
if (allPoints.size == 1) {
map.controller.setCenter(allPoints.first())
map.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
} else {
map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), true)
}
hasCenteredTraceroute = true
}
}
fun MapView.generateBoxOverlay() {
overlays.removeAll { it is Polygon }
val zoomFactor = 1.3
@ -689,51 +569,6 @@ fun MapView(
}
}
fun MapView.onTracksChanged(nodeTracks: List<Position>?, focusedNodeNum: Int?): Pair<List<Marker>, List<Polyline>> {
if (nodeTracks == null || focusedNodeNum == null) return emptyList<Marker>() to emptyList<Polyline>()
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
val timeFilteredPositions =
nodeTracks.filter {
lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
}
val sortedPositions = timeFilteredPositions.sortedBy { it.time }
val focusedNode = nodes.find { it.num == focusedNodeNum } ?: return emptyList<Marker>() to emptyList<Polyline>()
val color = focusedNode.colors.second
val trackPolylines = mutableListOf<Polyline>()
if (sortedPositions.size > 1) {
val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
segments.forEachIndexed { index, segmentPoints ->
val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
val polyline =
Polyline().apply {
setPoints(
segmentPoints.map { GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) },
)
outlinePaint.color = Color(color).copy(alpha = alpha).toArgb()
outlinePaint.strokeWidth = 8f
}
trackPolylines.add(polyline)
}
}
val trackMarkers =
sortedPositions.mapIndexedNotNull { index, position ->
if (index == sortedPositions.lastIndex) return@mapIndexedNotNull null
Marker(this).apply {
this.position = GeoPoint((position.latitude_i ?: 0) * 1e-7, (position.longitude_i ?: 0) * 1e-7)
icon = AppCompatResources.getDrawable(context, R.drawable.ic_map_location_dot)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
title = getString(Res.string.position)
snippet = formatAgo(position.time)
}
}
return trackMarkers to trackPolylines
}
Scaffold(
modifier = modifier,
floatingActionButton = {
@ -750,14 +585,10 @@ fun MapView(
},
modifier = Modifier.fillMaxSize(),
update = { mapView ->
mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints)
val (trackMarkers, trackPolylines) = mapView.onTracksChanged(nodeTracks, focusedNodeNum)
with(mapView) {
updateMarkers(
onNodesChanged(nodesForMarkers),
onNodesChanged(nodes),
onWaypointChanged(waypoints.values, selectedWaypointId),
trackMarkers,
trackPolylines,
nodeClusterer,
)
}
@ -776,122 +607,34 @@ fun MapView(
modifier = Modifier.align(Alignment.BottomCenter),
)
} else {
@Suppress("MagicNumber")
Column(
modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
MapButton(
onClick = { showMapStyleDialog = true },
icon = MeshtasticIcons.Layers,
contentDescription = Res.string.map_style_selection,
)
Box(modifier = Modifier) {
MapButton(
onClick = { mapFilterExpanded = true },
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
)
DropdownMenu(
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
onToggleFilterMenu = { mapFilterExpanded = true },
filterDropdownContent = {
FdroidMainMapFilterDropdown(
expanded = mapFilterExpanded,
onDismissRequest = { mapFilterExpanded = false },
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
) {
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(Res.string.only_favorites),
modifier = Modifier.weight(1f),
)
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleOnlyFavorites() },
)
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(Res.string.show_waypoints),
modifier = Modifier.weight(1f),
)
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
)
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(Res.string.show_precision_circle),
modifier = Modifier.weight(1f),
)
@Suppress("MagicNumber")
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
}
}
MapButton(
icon =
if (myLocationOverlay == null) {
MeshtasticIcons.MyLocation
} else {
MeshtasticIcons.LocationDisabled
},
contentDescription = stringResource(Res.string.toggle_my_position),
) {
mapFilterState = mapFilterState,
mapViewModel = mapViewModel,
)
},
mapTypeContent = {
MapButton(
icon = MeshtasticIcons.Layers,
contentDescription = stringResource(Res.string.map_style_selection),
onClick = { showMapStyleDialog = true },
)
},
isLocationTrackingEnabled = myLocationOverlay != null,
onToggleLocationTracking = {
if (locationPermissionsState.allPermissionsGranted) {
map.toggleMyLocation()
} else {
triggerLocationToggleAfterPermission = true
locationPermissionsState.launchMultiplePermissionRequest()
}
}
}
},
)
}
}
}
@ -970,6 +713,103 @@ fun MapView(
}
}
/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */
@Composable
private fun FdroidMainMapFilterDropdown(
expanded: Boolean,
onDismissRequest: () -> Unit,
mapFilterState: MapFilterState,
mapViewModel: MapViewModel,
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
) {
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleOnlyFavorites() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
HorizontalDivider()
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(mapFilterState.lastHeardFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
}
@Composable
private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) {
val selected = remember { mutableStateOf(selectedMapStyle) }
@ -1125,57 +965,4 @@ private fun MapsDialog(
}
}
private const val EARTH_RADIUS_METERS = 6_371_000.0
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
private const val WAYPOINT_ZOOM = 15.0
@Suppress("MagicNumber")
private fun Double.toRad(): Double = this * Math.PI / 180.0
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
val lat1 = from.latitude.toRad()
val lat2 = to.latitude.toRad()
val dLon = (to.longitude - from.longitude).toRad()
return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
}
private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
val lat1 = latitude.toRad()
val lon1 = longitude.toRad()
val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
val lon2 =
lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2))
}
private fun offsetPolyline(
points: List<GeoPoint>,
offsetMeters: Double,
headingReferencePoints: List<GeoPoint> = points,
sideMultiplier: Double = 1.0,
): List<GeoPoint> {
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
val headings =
headingPoints.mapIndexed { index, _ ->
when (index) {
0 -> bearingRad(headingPoints[0], headingPoints[1])
headingPoints.lastIndex ->
bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
}
}
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
@Suppress("MagicNumber")
val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier)
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
}
}

View file

@ -16,9 +16,6 @@
*/
package org.meshtastic.app.map
import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@ -32,7 +29,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import co.touchlab.kermit.Logger
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
@ -41,29 +37,6 @@ import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
@SuppressLint("WakelockTimeout")
private fun PowerManager.WakeLock.safeAcquire() {
if (!isHeld) {
try {
acquire()
} catch (e: SecurityException) {
Logger.e { "WakeLock permission exception: ${e.message}" }
} catch (e: IllegalStateException) {
Logger.e { "WakeLock acquire() exception: ${e.message}" }
}
}
}
private fun PowerManager.WakeLock.safeRelease() {
if (isHeld) {
try {
release()
} catch (e: IllegalStateException) {
Logger.e { "WakeLock release() exception: ${e.message}" }
}
}
}
private const val MIN_ZOOM_LEVEL = 1.5
private const val MAX_ZOOM_LEVEL = 20.0
private const val DEFAULT_ZOOM_LEVEL = 15.0
@ -136,22 +109,13 @@ internal fun rememberMapViewWithLifecycle(
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
@Suppress("DEPRECATION")
val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock")
wakeLock.safeAcquire()
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
wakeLock.safeRelease()
mapView.onPause()
}
Lifecycle.Event.ON_RESUME -> {
wakeLock.safeAcquire()
mapView.onResume()
}
@ -166,10 +130,7 @@ internal fun rememberMapViewWithLifecycle(
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
wakeLock.safeRelease()
}
onDispose { lifecycle.removeObserver(observer) }
}
return mapView
}

View file

@ -1,61 +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.app.map.component
import androidx.compose.foundation.layout.size
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun MapButton(
icon: ImageVector,
contentDescription: StringResource,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
MapButton(
icon = icon,
contentDescription = stringResource(contentDescription),
modifier = modifier,
onClick = onClick,
)
}
@Composable
fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
FloatingActionButton(onClick = onClick, modifier = modifier) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
}
}
@PreviewLightDark
@Composable
private fun MapButtonPreview() {
AppTheme { MapButton(icon = MeshtasticIcons.Layers, contentDescription = Res.string.map_style_selection) }
}

View file

@ -17,48 +17,38 @@
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addPolyline
import org.meshtastic.app.map.addPositionMarkers
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
private const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val density = LocalDensity.current
val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
val geoPoints = positionLogs.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = nodeMapViewModel.applicationId,
box = cameraView,
tileSource = CustomTileSource.getTileSource(nodeMapViewModel.mapStyleId),
)
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { mapView },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(positionLogs) {}
Scaffold(
topBar = {
MainAppBar(
title = node?.user?.long_name ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
)
) { paddingValues ->
NodeTrackOsmMap(
positions = positions,
applicationId = nodeMapViewModel.applicationId,
mapStyleId = nodeMapViewModel.mapStyleId,
modifier = Modifier.fillMaxSize().padding(paddingValues),
)
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 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.app.map.node
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain
* [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation
* ([NodeTrackOsmMap]).
*/
@Composable
fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
NodeTrackOsmMap(
positions = positions,
applicationId = vm.applicationId,
mapStyleId = vm.mapStyleId,
modifier = modifier,
)
}

View file

@ -0,0 +1,150 @@
/*
* Copyright (c) 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.app.map.node
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addPolyline
import org.meshtastic.app.map.addPositionMarkers
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.component.MapControlsOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.proto.Position
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import kotlin.math.roundToInt
/**
* A focused OSMDroid map composable that renders **only** a node's position track a dashed polyline with directional
* markers for each historical position.
*
* Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter]
* from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a
* minimal [MapControlsOverlay][org.meshtastic.app.map.component.MapControlsOverlay] with a track time filter slider so
* users can adjust the time range directly from the map.
*
* Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or
* location tracking. It is designed to be embedded inside the position-log adaptive layout.
*/
@Composable
fun NodeTrackOsmMap(
positions: List<Position>,
applicationId: String,
mapStyleId: Int,
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
) {
val density = LocalDensity.current
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
val filteredPositions =
remember(positions, lastHeardTrackFilter) {
positions.filter {
lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
}
}
val geoPoints =
remember(filteredPositions) {
filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
}
val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = applicationId,
box = cameraView,
tileSource = CustomTileSource.getTileSource(mapStyleId),
)
var filterMenuExpanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
AndroidView(
modifier = Modifier.matchParentSize(),
factory = { mapView },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(filteredPositions) {}
},
)
// Track filter controls overlay
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
onToggleFilterMenu = { filterMenuExpanded = true },
filterDropdownContent = {
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(lastHeardTrackFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
},
)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 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.app.map.traceroute
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation
* ([TracerouteOsmMap]).
*/
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
TracerouteOsmMap(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
}

View file

@ -0,0 +1,288 @@
/*
* Copyright (c) 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.app.map.traceroute
import android.graphics.Paint
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.R
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.app.map.zoomIn
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.Position
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
/**
* A focused OSMDroid map composable that renders **only** traceroute visualization node markers for each hop and
* forward/return offset polylines with auto-centering camera.
*
* Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any
* map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold.
*/
@Composable
fun TracerouteOsmMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
) {
val context = LocalContext.current
val density = LocalDensity.current
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
// Resolve which nodes to display for the traceroute
val tracerouteSelection =
remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
mapViewModel.tracerouteNodeSelection(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
nodes = nodes,
)
}
val displayNodes = tracerouteSelection.nodesForMarkers
val nodeLookup = tracerouteSelection.nodeLookup
// Report mappable count
LaunchedEffect(tracerouteOverlay, displayNodes) {
if (tracerouteOverlay != null) {
onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
}
}
// Compute polyline GeoPoints from node positions
val forwardPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.forwardRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
val returnPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.returnRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
// Compute offset polylines for visual separation
val headingReferencePoints =
remember(forwardPoints, returnPoints) {
when {
forwardPoints.size >= 2 -> forwardPoints
returnPoints.size >= 2 -> returnPoints
else -> emptyList()
}
}
val forwardOffsetPoints =
remember(forwardPoints, headingReferencePoints) {
offsetPolyline(
points = forwardPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = headingReferencePoints,
sideMultiplier = 1.0,
)
}
val returnOffsetPoints =
remember(returnPoints, headingReferencePoints) {
offsetPolyline(
points = returnPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = headingReferencePoints,
sideMultiplier = -1.0,
)
}
// Camera auto-center
var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) }
// Build initial camera from all traceroute points
val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() }
val initialCameraView =
remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = mapViewModel.applicationId,
box = initialCameraView ?: BoundingBox(),
tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId),
)
// Center camera on traceroute bounds
LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) {
if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect
if (allPoints.isNotEmpty()) {
if (allPoints.size == 1) {
mapView.controller.setCenter(allPoints.first())
mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
} else {
mapView.zoomToBoundingBox(
BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS),
true,
)
}
hasCentered = true
}
}
AndroidView(
modifier = modifier,
factory = { mapView.apply { setDestroyMode(false) } },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
// Render traceroute polylines
buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) }
// Render simple node markers
displayNodes.forEach { node ->
val position = GeoPoint(node.latitude, node.longitude)
val marker =
MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}")
.apply {
id = node.user.id
title = node.user.long_name
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
this.position = position
icon = markerIcon
setNodeColors(node.colors)
}
map.overlays.add(marker)
}
map.invalidate()
},
)
}
private fun buildTraceroutePolylines(
forwardPoints: List<GeoPoint>,
returnPoints: List<GeoPoint>,
density: androidx.compose.ui.unit.Density,
): List<Polyline> {
val polylines = mutableListOf<Polyline>()
fun buildPolyline(points: List<GeoPoint>, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
setPoints(points)
outlinePaint.apply {
this.color = color
this.strokeWidth = strokeWidth
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
style = Paint.Style.STROKE
}
}
forwardPoints
.takeIf { it.size >= 2 }
?.let { points ->
polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }))
}
returnPoints
.takeIf { it.size >= 2 }
?.let { points ->
polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }))
}
return polylines
}
// --- Haversine offset math for OSMDroid (no SphericalUtil available) ---
private fun Double.toRad(): Double = this * PI / 180.0
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
val lat1 = from.latitude.toRad()
val lat2 = to.latitude.toRad()
val dLon = (to.longitude - from.longitude).toRad()
return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
}
private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
val lat1 = latitude.toRad()
val lon1 = longitude.toRad()
val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
val lon2 =
lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI)
}
private fun offsetPolyline(
points: List<GeoPoint>,
offsetMeters: Double,
headingReferencePoints: List<GeoPoint> = points,
sideMultiplier: Double = 1.0,
): List<GeoPoint> {
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
val headings =
headingPoints.mapIndexed { index, _ ->
when (index) {
0 -> bearingRad(headingPoints[0], headingPoints[1])
headingPoints.lastIndex ->
bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
}
}
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
val perpendicularHeading = heading + (PI / 2 * sideMultiplier)
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
}
}

View file

@ -23,31 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
/** Google Maps implementation of [MapViewProvider]. */
@Single
class GoogleMapViewProvider : MapViewProvider {
@Composable
override fun MapView(
modifier: Modifier,
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int?,
nodeTracks: List<Any>?,
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
waypointId: Int?,
) {
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
focusedNodeNum = focusedNodeNum,
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}

File diff suppressed because it is too large Load diff

View file

@ -42,9 +42,9 @@ import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Place
import org.meshtastic.core.ui.icon.RadioButtonUnchecked
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.feature.map.LastHeardFilter
import kotlin.math.roundToInt
@ -73,7 +73,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Place,
imageVector = MeshtasticIcons.PinDrop,
contentDescription = stringResource(Res.string.show_waypoints),
)
},
@ -89,7 +89,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.RadioButtonUnchecked, // Placeholder icon
imageVector = MeshtasticIcons.Lens,
contentDescription = stringResource(Res.string.show_precision_circle),
)
},

View file

@ -25,14 +25,13 @@ import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
private const val DEG_D = 1e-7
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,

View file

@ -16,13 +16,14 @@
*/
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
@ -31,7 +32,6 @@ import org.meshtastic.feature.map.node.NodeMapViewModel
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
val destNum = node?.num
Scaffold(
topBar = {
@ -46,8 +46,9 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
)
},
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
}
MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 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.app.map.node
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a
* [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode,
* which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track
* filter).
*/
@Composable
fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
val focusedNode by vm.node.collectAsStateWithLifecycle()
MapView(modifier = modifier, mode = GoogleMapMode.NodeTrack(focusedNode = focusedNode, positions = positions))
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 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.app.map.traceroute
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute]
* mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay).
*/
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
MapView(
modifier = modifier,
mode =
GoogleMapMode.Traceroute(
overlay = tracerouteOverlay,
nodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
),
)
}

View file

@ -69,14 +69,24 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
@ -164,32 +174,42 @@ class MainActivity : ComponentActivity() {
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier ->
org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier)
},
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
org.meshtastic.core.ui.util.LocalNodeMapScreenProvider provides
LocalTracerouteMapProvider provides
{ overlay, nodePositions, onMappableCountChanged, modifier ->
org.meshtastic.app.map.traceroute.TracerouteMap(
tracerouteOverlay = overlay,
tracerouteNodePositions = nodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
},
LocalNodeMapScreenProvider provides
{ destNum, onNavigateUp ->
val vm = koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp ->
val metricsViewModel =
koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel> {
org.koin.core.parameter.parametersOf(destNum)
}
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) }
metricsViewModel.setNodeId(destNum)
org.meshtastic.feature.node.metrics.TracerouteMapScreen(
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = requestId,
logUuid = logUuid,
onNavigateUp = onNavigateUp,
)
},
org.meshtastic.core.ui.util.LocalMapMainScreenProvider provides
LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId ->
val viewModel = koinViewModel<org.meshtastic.feature.map.SharedMapViewModel>()
org.meshtastic.feature.map.MapScreen(
val viewModel = koinViewModel<SharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails,

View file

@ -24,13 +24,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
/**
* A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance
* across both Google and F-Droid flavors.
*/
@Composable
fun MapButton(
modifier: Modifier = Modifier,
icon: ImageVector,
iconTint: Color? = null,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
iconTint: Color? = null,
) {
FilledIconButton(onClick = onClick, modifier = modifier) {
Icon(

View file

@ -27,17 +27,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.LocationDisabled
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
@ -45,77 +40,58 @@ import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Tune
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
/**
* Shared map controls overlay used by both Google and F-Droid map views. Provides compass, filter button, location
* tracking button, and optional slots for flavor-specific content (map type selector, layers, refresh).
*
* @param onToggleFilterMenu Callback to open/close the filter dropdown.
* @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button typically a
* `DropdownMenu` with filter options.
* @param mapTypeContent Optional composable for a map type selector button + dropdown. Google flavor provides map type
* and custom tile options; F-Droid provides a tile source selector.
* @param layersContent Optional composable for a layers management button.
* @param showRefresh Whether to show a refresh button (e.g., for network map layers).
* @param isRefreshing Whether a refresh is currently in progress.
* @param onRefresh Callback when the refresh button is clicked.
*/
@Suppress("LongParameterList")
@Composable
fun MapControlsOverlay(
onToggleFilterMenu: () -> Unit,
modifier: Modifier = Modifier,
mapFilterMenuExpanded: Boolean,
onMapFilterMenuDismissRequest: () -> Unit,
onToggleMapFilterMenu: () -> Unit,
mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown
mapTypeMenuExpanded: Boolean,
onMapTypeMenuDismissRequest: () -> Unit,
onToggleMapTypeMenu: () -> Unit,
onManageLayersClicked: () -> Unit,
onManageCustomTileProvidersClicked: () -> Unit, // New parameter
isNodeMap: Boolean,
// Location tracking parameters
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
bearing: Float = 0f,
onCompassClick: () -> Unit = {},
followPhoneBearing: Boolean,
followPhoneBearing: Boolean = false,
filterDropdownContent: @Composable () -> Unit = {},
mapTypeContent: @Composable () -> Unit = {},
layersContent: @Composable () -> Unit = {},
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
showRefresh: Boolean = false,
isRefreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
Row(modifier = modifier) {
// Compass
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
if (isNodeMap) {
// Filter button + dropdown
Box {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleMapFilterMenu,
onClick = onToggleFilterMenu,
)
NodeMapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
} else {
Box {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleMapFilterMenu,
)
MapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
}
filterDropdownContent()
}
Box {
MapButton(
icon = MeshtasticIcons.Map,
contentDescription = stringResource(Res.string.map_tile_source),
onClick = onToggleMapTypeMenu,
)
MapTypeDropdown(
expanded = mapTypeMenuExpanded,
onDismissRequest = onMapTypeMenuDismissRequest,
mapViewModel = mapViewModel, // Pass mapViewModel
onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback
)
}
// Map type selector (flavor-specific)
mapTypeContent()
MapButton(
icon = MeshtasticIcons.Layers,
contentDescription = stringResource(Res.string.manage_map_layers),
onClick = onManageLayersClicked,
)
// Layers button (flavor-specific)
layersContent()
// Refresh button (optional)
if (showRefresh) {
if (isRefreshing) {
Box(modifier = Modifier.padding(8.dp)) {
@ -132,12 +108,7 @@ fun MapControlsOverlay(
// Location tracking button
MapButton(
icon =
if (isLocationTrackingEnabled) {
MeshtasticIcons.LocationDisabled
} else {
MeshtasticIcons.MyLocation
},
icon = if (isLocationTrackingEnabled) MeshtasticIcons.LocationDisabled else MeshtasticIcons.MyLocation,
contentDescription = stringResource(Res.string.toggle_my_position),
onClick = onToggleLocationTracking,
)
@ -146,12 +117,16 @@ fun MapControlsOverlay(
@Composable
private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) {
val icon = if (isFollowing) MeshtasticIcons.MapCompass else MeshtasticIcons.MapCompass
val iconTint =
when {
isFollowing -> MaterialTheme.colorScheme.primary
bearing == 0f -> MaterialTheme.colorScheme.StatusRed
else -> null
}
MapButton(
modifier = Modifier.rotate(-bearing),
icon = icon,
iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { bearing == 0f },
icon = MeshtasticIcons.MapCompass,
iconTint = iconTint,
contentDescription = stringResource(Res.string.orient_north),
onClick = onClick,
)

View file

@ -14,15 +14,24 @@
* 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.map.model
package org.meshtastic.core.model
/**
* Represents a traceroute result with forward and return routes as ordered lists of node nums.
*
* @property requestId The mesh packet request ID that initiated this traceroute.
* @property forwardRoute Ordered node nums along the path towards the destination.
* @property returnRoute Ordered node nums along the return path back to the originator.
*/
data class TracerouteOverlay(
val requestId: Int,
val forwardRoute: List<Int> = emptyList(),
val returnRoute: List<Int> = emptyList(),
) {
/** All unique node nums involved in either route direction. */
val relatedNodeNums: Set<Int> = (forwardRoute + returnRoute).toSet()
/** True if at least one route direction contains nodes. */
val hasRoutes: Boolean
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 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.core.model.util
/** Common geographic constants for coordinate conversions. */
object GeoConstants {
/** Multiplier to convert protobuf integer coordinates (1e-7 degree units) to decimal degrees. */
const val DEG_D = 1e-7
/** Multiplier to convert protobuf integer heading values (1e-5 degree units) to decimal degrees. */
const val HEADING_DEG = 1e-5
/** Mean radius of the Earth in meters, for haversine calculations. */
const val EARTH_RADIUS_METERS = 6_371_000.0
}

View file

@ -207,7 +207,7 @@ object DeepLinkRouter {
private val nodeDetailSubRoutes: Map<String, (Int) -> Route> =
mapOf(
"device-metrics" to { destNum -> NodeDetailRoute.DeviceMetrics(destNum) },
"map" to { destNum -> NodeDetailRoute.NodeMap(destNum) },
"map" to { destNum -> NodeDetailRoute.PositionLog(destNum) },
"position" to { destNum -> NodeDetailRoute.PositionLog(destNum) },
"environment" to { destNum -> NodeDetailRoute.EnvironmentMetrics(destNum) },
"signal" to { destNum -> NodeDetailRoute.SignalMetrics(destNum) },

View file

@ -74,8 +74,6 @@ sealed interface NodesRoute : Route {
sealed interface NodeDetailRoute : Route {
@Serializable data class DeviceMetrics(val destNum: Int) : NodeDetailRoute
@Serializable data class NodeMap(val destNum: Int) : NodeDetailRoute
@Serializable data class PositionLog(val destNum: Int) : NodeDetailRoute
@Serializable data class EnvironmentMetrics(val destNum: Int) : NodeDetailRoute

View file

@ -156,7 +156,7 @@ class DeepLinkRouterTest {
listOf(
NodesRoute.NodesGraph,
NodesRoute.NodeDetailGraph(destNum = 5678),
NodeDetailRoute.NodeMap(destNum = 5678),
NodeDetailRoute.PositionLog(destNum = 5678),
),
route("/nodes/5678/map"),
)

View file

@ -62,7 +62,6 @@ class NavigationConfigTest {
NodesRoute.NodeDetail(),
// NodeDetailRoute
NodeDetailRoute.DeviceMetrics(destNum = 100),
NodeDetailRoute.NodeMap(destNum = 100),
NodeDetailRoute.PositionLog(destNum = 100),
NodeDetailRoute.EnvironmentMetrics(destNum = 100),
NodeDetailRoute.SignalMetrics(destNum = 100),

View file

@ -889,6 +889,12 @@
<string name="type_a_message">Type a message</string>
<string name="pax_metrics_log">PAX Metrics</string>
<string name="pax">PAX</string>
<string name="pax_total_format">PAX: %1$d</string>
<string name="pax_ble_format">B:%1$d</string>
<string name="pax_wifi_format">W:%1$d</string>
<string name="pax_total_marker">PAX: %1$s</string>
<string name="pax_ble_marker">BLE: %1$s</string>
<string name="pax_wifi_marker">WiFi: %1$s</string>
<string name="no_pax_metrics_logs">No PAX metrics available.</string>
<string name="wifi_devices">Wi-Fi Provisioning for mPWRD-OS</string>
<string name="ble_devices">Bluetooth Devices</string>

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 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.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.component.PlaceholderScreen
import org.meshtastic.proto.Position
/**
* Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions].
* Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar it is designed to be embedded
* inside another screen layout (e.g. the position-log adaptive layout).
*
* On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping")
val LocalNodeTrackMapProvider =
compositionLocalOf<@Composable (destNum: Int, positions: List<Position>, modifier: Modifier) -> Unit> {
{ _, _, _ -> PlaceholderScreen("Position Track Map") }
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 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.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.ui.component.PlaceholderScreen
import org.meshtastic.proto.Position
/**
* Provides an embeddable traceroute map composable that renders node markers and forward/return offset polylines for a
* traceroute result. Unlike [LocalMapViewProvider], this does **not** include a Scaffold, AppBar, waypoints, location
* tracking, custom tiles, or any main-map features it is designed to be embedded inside `TracerouteMapScreen`'s
* scaffold.
*
* On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
*
* Parameters:
* - `tracerouteOverlay`: The overlay with forward/return route node nums.
* - `tracerouteNodePositions`: Map of node num to position snapshots for the route nodes.
* - `onMappableCountChanged`: Callback with (shown, total) node counts.
* - `modifier`: Compose modifier for the map.
*/
@Suppress("Wrapping")
val LocalTracerouteMapProvider =
compositionLocalOf<
@Composable (
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (Int, Int) -> Unit,
modifier: Modifier,
) -> Unit,
> {
{ _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
}

View file

@ -22,23 +22,10 @@ import androidx.compose.ui.Modifier
/**
* Interface for providing a flavored MapView. This allows the map feature to be decoupled from specific map
* implementations (Google Maps vs osmdroid).
* implementations (Google Maps vs OSMDroid). Platform implementations create their own ViewModel via Koin.
*/
interface MapViewProvider {
@Composable
fun MapView(
modifier: Modifier,
// We use Any here to avoid circular dependency with feature:map
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int? = null,
// Using List<Any> to avoid dependency on proto.Position if needed
nodeTracks: List<Any>? = null,
tracerouteOverlay: Any? = null,
tracerouteNodePositions: Map<Int, Any> = emptyMap(),
onTracerouteMappableCountChanged: (Int, Int) -> Unit = { _, _ -> },
waypointId: Int? = null,
)
@Composable fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int? = null)
}
val LocalMapViewProvider = compositionLocalOf<MapViewProvider?> { null }

View file

@ -26,7 +26,9 @@ Examples in current code:
- Platform/flavor UI implementations should be injected via `CompositionLocal` from app.
Examples:
- Contract: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
- Contract (main map): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
- Contract (node tracks): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt`
- Contract (traceroute): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt`
- Provider wiring: `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt`
## 4) DI and module activation checks

View file

@ -15,6 +15,8 @@ Key files for discovering established patterns:
| Shared ViewModel | `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt` |
| `CompositionLocal` platform injection | `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` |
| Platform abstraction contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` |
| Node track map provider contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` |
| Traceroute map provider contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` |
| Shared strings resource | `core/resources/src/commonMain/composeResources/values/strings.xml` |
| Okio shared I/O | `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt` |
| `stateInWhileSubscribed` | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt` |
@ -82,7 +84,9 @@ Reference examples:
4. Keep adapter types narrow and stable (interfaces, DTO-like params).
Reference examples:
- Contract: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
- Contract (main map): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
- Contract (node tracks): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt`
- Contract (traceroute): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt`
- Provider wiring: `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt`
- Consumer side: `feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt`

View file

@ -11,7 +11,7 @@ The codebase is **~98% structurally KMP** — 18/20 core modules and 8/8 feature
Of the five structural gaps originally identified, four are resolved and one remains in progress:
1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 6 files: `MainActivity`, `MeshUtilApplication`, Nav shell, and DI config)*
1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 8 files: `MainActivity`, `MeshUtilApplication`, Nav shell, DI config, and shared map UI components)*
2. ~~**Radio transport layer is app-locked**~~ — ✅ Resolved: `RadioTransport` interface in `core:repository/commonMain`; shared `StreamFrameCodec` + `TcpTransport` in `core:network`.
3. ~~**`java.*` APIs leak into `commonMain`**~~ — ✅ Resolved: `Locale`, `ConcurrentHashMap`, `ReentrantLock` purged.
4. ~~**Zero feature-level `commonTest`**~~ — ✅ Resolved: 193 shared tests across all 8 features; `core:testing` module established.
@ -24,7 +24,7 @@ Of the five structural gaps originally identified, four are resolved and one rem
| `core/*/commonMain` | 337 | 32,700 | Shared business/data logic |
| `feature/*/commonMain` | 146 | 19,700 | Shared feature UI + ViewModels |
| `feature/*/androidMain` | 62 | 14,700 | Platform UI (charts, previews, permissions) |
| `app/src/main` | 6 | ~300 | Android app shell (target achieved) |
| `app/src/main` | 8 | ~450 | Android app shell + shared map UI components |
| `desktop/src` | 26 | 4,800 | Desktop app shell |
| `core/*/androidMain` | 49 | 3,500 | Platform implementations |
| `core/*/jvmMain` | 11 | ~500 | JVM actuals |
@ -38,7 +38,7 @@ Of the five structural gaps originally identified, four are resolved and one rem
### A1. `app` module is a God module
The `app` module should be a thin shell (~20 files): `MainActivity`, DI assembly, nav host. Originally it held **90 files / ~11K LOC**, now completely reduced to a **6-file shell**:
The `app` module should be a thin shell (~20 files): `MainActivity`, DI assembly, nav host, and shared flavor-agnostic UI. Originally it held **90 files / ~11K LOC**, now reduced to an **8-file shell** (6 original + 2 shared map UI components: `MapButton`, `MapControlsOverlay`):
| Area | Files | LOC | Where it should live |
|---|---:|---:|---|

View file

@ -1,6 +1,6 @@
# KMP Migration Status
> Last updated: 2026-03-31
> Last updated: 2026-04-10
Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/).
@ -49,7 +49,7 @@ Modules that share JVM-specific code between Android and desktop now standardize
| `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; fully shared `contactsGraph`, `MessageScreen`, `ContactsScreen`, and `MessageListPaged` |
| `feature:connections` | ✅ | ✅ Shared `ConnectionsScreen` with dynamic transport detection |
| `feature:intro` | — | — | Screens remain in `androidMain`; shared ViewModel only |
| `feature:map` | — | Placeholder; shared `NodeMapViewModel` and `BaseMapViewModel` only |
| `feature:map` | — | Placeholder; shared `NodeMapViewModel`, `BaseMapViewModel`, and `TracerouteNodeSelection`. Map rendering decomposed into 3 `CompositionLocal` provider contracts (`MapViewProvider`, `NodeTrackMapProvider`, `TracerouteMapProvider`) with per-flavor implementations in `:app` |
| `feature:firmware` | ✅ | ✅ Fully KMP: Unified OTA, native Secure DFU, USB/UF2, FirmwareRetriever |
| `feature:wifi-provision` | ✅ | ✅ KMP WiFi provisioning via BLE (Nymea protocol); shared UI and ViewModel |
| `feature:widget` | ❌ | — | Android-only (Glance appwidgets). Intentional. |
@ -144,6 +144,8 @@ Extracted to shared `commonMain` (no longer app-only):
- `ChannelViewModel``feature:settings/commonMain`
- `NodeMapViewModel``feature:map/commonMain` (Shared logic for node-specific maps)
- `BaseMapViewModel``feature:map/commonMain` (Core contract for all maps)
- `TracerouteOverlay``core:model/commonMain` (Pure data class for traceroute route segments; extracted from `feature:map` for cross-module reuse)
- `GeoConstants``core:model/commonMain` (Centralized `DEG_D`, `HEADING_DEG`, `EARTH_RADIUS_METERS` constants; eliminates 7 duplicate private constants)
Extracted to core KMP modules:
- Android Services, WorkManager Workers, and BroadcastReceivers → `core:service/androidMain`
@ -151,7 +153,7 @@ Extracted to core KMP modules:
- TCP radio connections, BLE radio connections (`BleRadioInterface`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations)
Remaining to be extracted from `:app` or unified in `commonMain`:
- `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface)
- `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface. `MapViewProvider` interface simplified — track rendering and traceroute rendering extracted to dedicated provider contracts)
## Prerelease Dependencies

View file

@ -1,6 +1,6 @@
# Roadmap
> Last updated: 2026-03-31
> Last updated: 2026-04-10
Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). For the full gap analysis, see [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md).
@ -81,10 +81,10 @@ These items address structural gaps identified in the March 2026 architecture re
1. **Evaluate KMP-native testing tools** — ✅ **Done:** Fully evaluated and integrated `Mokkery`, `Turbine`, and `Kotest` across the KMP modules. `mockk` has been successfully replaced, enabling property-based and Flow testing in `commonTest` for iOS readiness.
2. **Desktop Map Integration** — Address the major Desktop feature gap by implementing a raster map view using [**MapComposeMP**](https://github.com/p-lr/MapComposeMP).
- Implement a `MapComposeProvider` for Desktop.
- Implement Desktop providers for the 3 decomposed map contracts: `MapViewProvider` (main map), `NodeTrackMapProvider` (per-node track overlay for `PositionLogScreen`), and `TracerouteMapProvider` (traceroute visualization).
- Implement a **Web Mercator Projection** helper in `feature:map/commonMain` to translate GPS coordinates to the 2D image plane.
- Leverage the existing `BaseMapViewModel` contract.
3. **Unify `MapViewModel`** — Collapse the remaining Google and F-Droid specific `MapViewModel` classes in the `:app` module into a single `commonMain` implementation by isolating platform-specific settings (styles, tile sources) behind a repository interface.
- Leverage the existing `BaseMapViewModel` contract and `TracerouteNodeSelection` logic in `commonMain`.
3. **Unify `MapViewModel`** — Collapse the remaining Google and F-Droid specific `MapViewModel` classes in the `:app` module into a single `commonMain` implementation by isolating platform-specific settings (styles, tile sources) behind a repository interface. The `MapViewProvider` interface has been simplified (track/traceroute rendering extracted to dedicated providers), reducing the surface area of this unification.
4. **iOS CI gate** — ✅ **Done:** added `iosArm64()`/`iosSimulatorArm64()` to convention plugins and CI. `commonMain` successfully compiles on iOS.
## Medium-Term Priorities (60 days)

View file

@ -1,24 +1,41 @@
# `:feature:map`
## Overview
The `:feature:map` module provides the mapping interface for the application. It supports multiple map providers and displays node positions, tracks, and waypoints.
The `:feature:map` module provides the mapping interface for the application. Map rendering is decomposed into three focused `CompositionLocal` provider contracts, each with per-flavor implementations in `:app`.
## Key Components
## Architecture
### 1. `MapScreen`
The main mapping interface. It integrates with flavor-specific map implementations (Google Maps for `google`, OpenStreetMap for `fdroid`).
### Provider Contracts (in `core:ui/commonMain`)
### 2. `BaseMapViewModel`
The base logic for managing map state, node markers, and camera positions.
| Contract | Purpose | Implementations |
|---|---|---|
| `MapViewProvider` | Main map (nodes, waypoints, controls) | `GoogleMapViewProvider`, `FdroidMapViewProvider` |
| `NodeTrackMapProvider` | Per-node GPS track overlay (embedded in `PositionLogScreen`) | Google: `NodeTrackMap``MapView(GoogleMapMode.NodeTrack)`, F-Droid: `NodeTrackMap``NodeTrackOsmMap` |
| `TracerouteMapProvider` | Traceroute route visualization | Google: `TracerouteMap``MapView(GoogleMapMode.Traceroute)`, F-Droid: `TracerouteMap``TracerouteOsmMap` |
All providers are injected via `CompositionLocal` in `MainActivity.kt` and consumed by feature modules without direct dependency on Google Maps or osmdroid.
### Shared ViewModels (in `commonMain`)
- **`BaseMapViewModel`** — Core contract for all map state management, node markers, camera positions, and traceroute node selection logic (`TracerouteNodeSelection`, `tracerouteNodeSelection()`).
- **`NodeMapViewModel`** — Shared logic for per-node map views (track display, position history).
### Key Data Types
- **`TracerouteOverlay`** (`core:model/commonMain`) — Pure data class representing traceroute route segments. Extracted from `feature:map` for cross-module reuse.
- **`TracerouteNodeSelection`** (`feature:map/commonMain`) — Data class modeling node selection results during traceroute visualization.
- **`GeoConstants`** (`core:model/commonMain`) — Centralized geographic constants (`DEG_D`, `HEADING_DEG`, `EARTH_RADIUS_METERS`).
## Map Providers
- **Google Maps (`google` flavor)**: Uses Google Play Services Maps SDK.
- **OpenStreetMap (`fdroid` flavor)**: Uses `osmdroid` for a fully open-source mapping experience.
- **Google Maps (`google` flavor)**: Uses Google Play Services Maps SDK. Implementations in `app/src/google/kotlin/org/meshtastic/app/map/`.
- **OpenStreetMap (`fdroid` flavor)**: Uses `osmdroid` for a fully open-source experience. Implementations in `app/src/fdroid/kotlin/org/meshtastic/app/map/`.
## Features
- **Live Node Tracking**: Real-time position updates for nodes on the mesh.
- **Waypoints**: Create and share points of interest.
- **Per-Node Track Overlay**: Embedded map in `PositionLogScreen` showing a node's GPS track history.
- **Traceroute Visualization**: Dedicated map view showing route segments between mesh nodes.
- **Offline Maps**: Support for pre-downloaded map tiles (via `osmdroid`).
## Module dependency graph

View file

@ -57,7 +57,6 @@ fun MapScreen(
) { paddingValues ->
LocalMapViewProvider.current?.MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
viewModel = viewModel,
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)

View file

@ -31,6 +31,7 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@ -41,7 +42,6 @@ import org.meshtastic.core.resources.one_day
import org.meshtastic.core.resources.one_hour
import org.meshtastic.core.resources.two_days
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
@ -194,16 +194,42 @@ open class BaseMapViewModel(
)
}
/**
* Result of resolving a [TracerouteOverlay]'s node nums into displayable [Node] instances.
*
* @property overlayNodeNums All unique node nums referenced by the traceroute.
* @property nodesForMarkers Nodes to render as map markers (with snapshot positions when available).
* @property nodeLookup Node-num-keyed map for polyline coordinate resolution.
*/
data class TracerouteNodeSelection(
val overlayNodeNums: Set<Int>,
val nodesForMarkers: List<Node>,
val nodeLookup: Map<Int, Node>,
)
/** Convenience extension that delegates to [tracerouteNodeSelection] using the VM's [getNodeOrFallback]. */
fun BaseMapViewModel.tracerouteNodeSelection(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
nodes: List<Node>,
): TracerouteNodeSelection = tracerouteNodeSelection(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
nodes = nodes,
getNodeOrFallback = ::getNodeOrFallback,
)
/**
* Resolves traceroute overlay node nums into displayable [Node] instances. Snapshot positions (recorded at traceroute
* time) take priority over live positions from the node database.
*
* @param getNodeOrFallback Provides a [Node] for a given num, falling back to a stub if not in the DB.
*/
fun tracerouteNodeSelection(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
nodes: List<Node>,
getNodeOrFallback: (Int) -> Node,
): TracerouteNodeSelection {
val overlayNodeNums = tracerouteOverlay?.relatedNodeNums ?: emptySet()
val tracerouteSnapshotNodes =

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 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.map
import kotlin.test.Test
import kotlin.test.assertEquals
@Suppress("MagicNumber")
class LastHeardFilterTest {
@Test
fun fromSeconds_knownValues() {
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(0L))
assertEquals(LastHeardFilter.OneHour, LastHeardFilter.fromSeconds(3600L))
assertEquals(LastHeardFilter.EightHours, LastHeardFilter.fromSeconds(28800L))
assertEquals(LastHeardFilter.OneDay, LastHeardFilter.fromSeconds(86400L))
assertEquals(LastHeardFilter.TwoDays, LastHeardFilter.fromSeconds(172800L))
}
@Test
fun fromSeconds_unknownValue_defaultsToAny() {
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(9999L))
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(-1L))
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(Long.MAX_VALUE))
}
@Test
fun seconds_matchExpectedValues() {
assertEquals(0L, LastHeardFilter.Any.seconds)
assertEquals(3600L, LastHeardFilter.OneHour.seconds)
assertEquals(28800L, LastHeardFilter.EightHours.seconds)
assertEquals(86400L, LastHeardFilter.OneDay.seconds)
assertEquals(172800L, LastHeardFilter.TwoDays.seconds)
}
}

View file

@ -0,0 +1,214 @@
/*
* Copyright (c) 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.map
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class TracerouteNodeSelectionTest {
private fun nodeWithPosition(num: Int, latI: Int = num * 100000, lonI: Int = num * 200000): Node =
Node(num = num, position = Position(latitude_i = latI, longitude_i = lonI))
private fun nodeWithoutPosition(num: Int): Node = Node(num = num, position = Position())
private val defaultGetNodeOrFallback: (Int) -> Node = { num -> Node(num = num) }
// ---- Null overlay (no traceroute active) ----
@Test
fun nullOverlay_returnsAllNodesUnfiltered() {
val nodes = listOf(nodeWithPosition(1), nodeWithPosition(2), nodeWithPosition(3))
val result =
tracerouteNodeSelection(
tracerouteOverlay = null,
tracerouteNodePositions = emptyMap(),
nodes = nodes,
getNodeOrFallback = defaultGetNodeOrFallback,
)
assertEquals(emptySet(), result.overlayNodeNums)
assertEquals(3, result.nodesForMarkers.size)
assertEquals(nodes.map { it.num }.toSet(), result.nodesForMarkers.map { it.num }.toSet())
}
@Test
fun nullOverlay_nodeLookupContainsOnlyNodesWithValidPositions() {
val nodes = listOf(nodeWithPosition(1), nodeWithoutPosition(2), nodeWithPosition(3))
val result =
tracerouteNodeSelection(
tracerouteOverlay = null,
tracerouteNodePositions = emptyMap(),
nodes = nodes,
getNodeOrFallback = defaultGetNodeOrFallback,
)
// nodeLookup filters to validPosition nodes when no snapshot
assertEquals(setOf(1, 3), result.nodeLookup.keys)
}
// ---- Overlay with snapshot positions ----
@Test
fun overlayWithSnapshot_usesSnapshotPositions() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(20, 10))
val snapshotPositions =
mapOf(
10 to Position(latitude_i = 400000000, longitude_i = -700000000),
20 to Position(latitude_i = 410000000, longitude_i = -710000000),
)
val liveNodes =
listOf(
nodeWithPosition(10, latI = 100000000, lonI = -100000000),
nodeWithPosition(20, latI = 200000000, lonI = -200000000),
nodeWithPosition(30),
)
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
nodes = liveNodes,
getNodeOrFallback = { num -> liveNodes.find { it.num == num } ?: Node(num = num) },
)
// Should use snapshot positions, not live ones
assertEquals(setOf(10, 20), result.overlayNodeNums)
assertEquals(2, result.nodesForMarkers.size)
assertEquals(400000000, result.nodesForMarkers.first { it.num == 10 }.position.latitude_i)
assertEquals(410000000, result.nodesForMarkers.first { it.num == 20 }.position.latitude_i)
}
@Test
fun overlayWithSnapshot_nodeLookupUsesSnapshotNodes() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
val snapshotPositions =
mapOf(
10 to Position(latitude_i = 400000000, longitude_i = -700000000),
20 to Position(latitude_i = 410000000, longitude_i = -710000000),
)
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
nodes = emptyList(),
getNodeOrFallback = { num -> Node(num = num) },
)
assertEquals(2, result.nodeLookup.size)
assertEquals(400000000, result.nodeLookup[10]?.position?.latitude_i)
}
@Test
fun overlayWithSnapshot_filtersToOverlayNodes() {
// Snapshot has node 30 which is NOT in the overlay routes
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
val snapshotPositions =
mapOf(
10 to Position(latitude_i = 400000000, longitude_i = -700000000),
20 to Position(latitude_i = 410000000, longitude_i = -710000000),
30 to Position(latitude_i = 420000000, longitude_i = -720000000),
)
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
nodes = emptyList(),
getNodeOrFallback = { num -> Node(num = num) },
)
// nodesForMarkers should only contain nodes in the overlay (10, 20), not 30
assertEquals(setOf(10, 20), result.nodesForMarkers.map { it.num }.toSet())
// but nodeLookup has all snapshot nodes (for polyline drawing)
assertEquals(3, result.nodeLookup.size)
}
// ---- Overlay without snapshot positions (live fallback) ----
@Test
fun overlayWithoutSnapshot_filtersLiveNodesToOverlayNums() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(30))
val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20), nodeWithPosition(30), nodeWithPosition(40))
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = emptyMap(),
nodes = liveNodes,
getNodeOrFallback = defaultGetNodeOrFallback,
)
assertEquals(setOf(10, 20, 30), result.overlayNodeNums)
assertEquals(setOf(10, 20, 30), result.nodesForMarkers.map { it.num }.toSet())
}
@Test
fun overlayWithoutSnapshot_nodeLookupFiltersToValidPositions() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
val liveNodes = listOf(nodeWithPosition(10), nodeWithoutPosition(20))
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = emptyMap(),
nodes = liveNodes,
getNodeOrFallback = defaultGetNodeOrFallback,
)
// nodeLookup only includes nodes with validPosition
assertEquals(setOf(10), result.nodeLookup.keys)
}
// ---- Edge cases ----
@Test
fun emptyOverlayRoutes_yieldsEmptySelection() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = emptyList(), returnRoute = emptyList())
val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20))
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = emptyMap(),
nodes = liveNodes,
getNodeOrFallback = defaultGetNodeOrFallback,
)
assertTrue(result.overlayNodeNums.isEmpty())
assertTrue(result.nodesForMarkers.isEmpty())
}
@Test
fun getNodeOrFallback_usedForSnapshotNodeLookup() {
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10))
val snapshotPositions = mapOf(10 to Position(latitude_i = 400000000, longitude_i = -700000000))
var lookupCalledWith: Int? = null
val result =
tracerouteNodeSelection(
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
nodes = emptyList(),
getNodeOrFallback = { num ->
lookupCalledWith = num
Node(num = num)
},
)
assertEquals(10, lookupCalledWith)
assertEquals(1, result.nodesForMarkers.size)
}
}

View file

@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.map.model
import org.meshtastic.core.model.TracerouteOverlay
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse

View file

@ -1,261 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.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 org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Message
import org.meshtastic.core.ui.icon.NotFavorite
import org.meshtastic.core.ui.icon.QrCode2
import org.meshtastic.core.ui.icon.VolumeMute
import org.meshtastic.core.ui.icon.VolumeOff
import org.meshtastic.core.ui.icon.VolumeUp
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.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.actions
import org.meshtastic.core.resources.direct_message
import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.share_contact
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@Composable
fun DeviceActions(
node: Node,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
onAction: (NodeDetailAction) -> Unit,
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
var displayMuteDialog by remember { mutableStateOf(false) }
var displayRemoveDialog by remember { mutableStateOf(false) }
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayFavoriteDialog,
displayIgnoreDialog = displayIgnoreDialog,
displayMuteDialog = displayMuteDialog,
displayRemoveDialog = displayRemoveDialog,
onDismissMenuRequest = {
displayFavoriteDialog = false
displayIgnoreDialog = false
displayMuteDialog = false
displayRemoveDialog = false
},
onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) },
onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
onConfirmMute = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(it))) },
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
)
ElevatedCard(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
shape = MaterialTheme.shapes.extraLarge,
) {
DeviceActionsContent(
node = node,
isLocal = isLocal,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
onAction = onAction,
onFavoriteClick = { displayFavoriteDialog = true },
onIgnoreClick = { displayIgnoreDialog = true },
onMuteClick = { displayMuteDialog = true },
onRemoveClick = { displayRemoveDialog = true },
)
}
}
@Composable
private fun DeviceActionsContent(
node: Node,
isLocal: Boolean,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
onAction: (NodeDetailAction) -> Unit,
onFavoriteClick: () -> Unit,
onIgnoreClick: () -> Unit,
onMuteClick: () -> Unit,
onRemoveClick: () -> Unit,
) {
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)
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, onIgnoreClick, onMuteClick, onRemoveClick)
}
}
@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(MeshtasticIcons.Message, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.direct_message))
}
}
OutlinedButton(
onClick = { onAction(NodeDetailAction.ShareContact) },
modifier = if (node.isEffectivelyUnmessageable || isLocal) Modifier.weight(1f) else Modifier,
shape = MaterialTheme.shapes.large,
) {
Icon(MeshtasticIcons.QrCode2, contentDescription = null)
if (node.isEffectivelyUnmessageable || isLocal) {
Spacer(Modifier.width(8.dp))
Text(stringResource(Res.string.share_contact))
}
}
IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
Icon(
imageVector = if (node.isFavorite) MeshtasticIcons.Favorite else MeshtasticIcons.NotFavorite,
contentDescription = stringResource(Res.string.favorite),
tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
)
}
}
}
@Composable
private fun ManagementActions(
node: Node,
onIgnoreClick: () -> Unit,
onMuteClick: () -> Unit,
onRemoveClick: () -> Unit,
) {
Column {
SwitchListItem(
text = stringResource(Res.string.ignore),
leadingIcon =
if (node.isIgnored) {
MeshtasticIcons.VolumeMute
} else {
MeshtasticIcons.VolumeUp
},
checked = node.isIgnored,
onClick = onIgnoreClick,
)
SwitchListItem(
text = stringResource(if (node.isMuted) Res.string.unmute else Res.string.mute_always),
leadingIcon = if (node.isMuted) {
MeshtasticIcons.VolumeOff
} else {
MeshtasticIcons.VolumeUp
},
checked = node.isMuted,
onClick = onMuteClick,
)
ListItem(
text = stringResource(Res.string.remove),
leadingIcon = MeshtasticIcons.Delete,
trailingIcon = null,
textColor = MaterialTheme.colorScheme.error,
leadingIconTint = MaterialTheme.colorScheme.error,
onClick = onRemoveClick,
)
}
}

View file

@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.flowOf
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.traceroute
@ -51,9 +52,8 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.proto.Position
@Composable
@ -117,16 +117,14 @@ private fun TracerouteMapScaffold(
},
) { paddingValues ->
Box(modifier = modifier.fillMaxSize().padding(paddingValues)) {
LocalMapViewProvider.current?.MapView(
modifier = Modifier,
viewModel = Unit,
navigateToNodeDetails = {},
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
onTracerouteMappableCountChanged = { shown: Int, total: Int ->
LocalTracerouteMapProvider.current(
overlay,
snapshotPositions,
{ shown: Int, total: Int ->
tracerouteNodesShown = shown
tracerouteNodesTotal = total
},
Modifier.fillMaxSize(),
)
Column(
modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding),

View file

@ -58,18 +58,20 @@ import org.meshtastic.core.ui.icon.VolumeMute
import org.meshtastic.core.ui.icon.VolumeOff
import org.meshtastic.core.ui.icon.VolumeUp
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.Config
@Composable
fun DeviceActions(
node: Node,
ourNode: Node?,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
availableLogs: Set<LogsType>,
onAction: (NodeDetailAction) -> Unit,
metricsState: MetricsState,
displayUnits: Config.DisplayConfig.DisplayUnits,
isFahrenheit: Boolean,
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
@ -85,10 +87,12 @@ fun DeviceActions(
TelemetricActionsSection(
node = node,
ourNode = ourNode,
availableLogs = availableLogs,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
metricsState = metricsState,
displayUnits = displayUnits,
isFahrenheit = isFahrenheit,
onAction = onAction,
isLocal = isLocal,
)

View file

@ -0,0 +1,168 @@
/*
* Copyright (c) 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/>.
*/
@file:Suppress("TooManyFunctions", "MagicNumber")
package org.meshtastic.feature.node.component
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.proto.Config
// ---------------------------------------------------------------------------
// Sample data for previews
// ---------------------------------------------------------------------------
private val previewData = NodePreviewParameterProvider()
// ---------------------------------------------------------------------------
// DeviceActions previews
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun DeviceActionsRemotePreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
DeviceActions(
node = node,
ourNode = previewData.mickeyMouse.copy(num = 9999),
lastTracerouteTime = null,
lastRequestNeighborsTime = null,
availableLogs =
setOf(
LogsType.DEVICE,
LogsType.POSITIONS,
LogsType.ENVIRONMENT,
LogsType.SIGNAL,
LogsType.TRACEROUTE,
),
onAction = {},
displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
isFahrenheit = false,
)
}
}
}
@PreviewLightDark
@Composable
private fun DeviceActionsLocalPreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
DeviceActions(
node = node,
ourNode = node,
lastTracerouteTime = null,
lastRequestNeighborsTime = null,
availableLogs = setOf(LogsType.DEVICE, LogsType.POSITIONS),
onAction = {},
displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
isFahrenheit = false,
isLocal = true,
)
}
}
}
// ---------------------------------------------------------------------------
// TelemetricActionsSection previews
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun TelemetricActionsSectionPreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
TelemetricActionsSection(
node = node,
ourNode = previewData.mickeyMouse.copy(num = 9999),
availableLogs =
setOf(
LogsType.DEVICE,
LogsType.POSITIONS,
LogsType.ENVIRONMENT,
LogsType.SIGNAL,
LogsType.TRACEROUTE,
LogsType.NEIGHBOR_INFO,
),
lastTracerouteTime = null,
lastRequestNeighborsTime = null,
displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
isFahrenheit = false,
onAction = {},
)
}
}
}
@PreviewLightDark
@Composable
private fun TelemetricActionsSectionEmptyPreview() {
val node = previewData.minnieMouse
AppTheme {
Surface {
TelemetricActionsSection(
node = node,
ourNode = previewData.mickeyMouse,
availableLogs = emptySet(),
lastTracerouteTime = null,
lastRequestNeighborsTime = null,
displayUnits = Config.DisplayConfig.DisplayUnits.IMPERIAL,
isFahrenheit = true,
onAction = {},
)
}
}
}
// ---------------------------------------------------------------------------
// PositionInlineContent preview
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun PositionInlineContentPreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
PositionInlineContent(
node = node,
ourNode = previewData.mickeyMouse.copy(num = 9999),
displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
onAction = {},
)
}
}
}
// ---------------------------------------------------------------------------
// NodeDetailsSection preview
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun NodeDetailsSectionPreview() {
val node = previewData.mickeyMouse
AppTheme { Surface { NodeDetailsSection(node = node) } }
}

View file

@ -16,10 +16,7 @@
*/
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
@ -28,9 +25,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -42,87 +36,48 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.position
import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.Distance
import org.meshtastic.core.ui.icon.LocationOn
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.LocalInlineMapProvider
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.Config
private const val EXCHANGE_BUTTON_WEIGHT = 1.1f
private const val COMPASS_BUTTON_WEIGHT = 0.9f
private const val MAP_HEIGHT_DP = 200
/**
* Displays node position details, last update time, distance, and related actions like requesting position and
* accessing map/position logs.
* Inline position content shown beneath the Position row in the Telemetry section. Displays the inline map with
* distance badge, linked coordinates, and compass button.
*/
@Composable
fun PositionSection(
internal fun PositionInlineContent(
node: Node,
ourNode: Node?,
metricsState: MetricsState,
availableLogs: Set<LogsType>,
displayUnits: Config.DisplayConfig.DisplayUnits,
onAction: (NodeDetailAction) -> Unit,
modifier: Modifier = Modifier,
) {
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits)
val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0
val isLocal = metricsState.isLocal
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(displayUnits)
SectionCard(title = Res.string.position, modifier = modifier) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
if (hasValidPosition) {
PositionMap(node, distance)
LinkedCoordinatesItem(node, metricsState.displayUnits)
Spacer(Modifier.height(8.dp))
}
PositionActionButtons(
node = node,
isLocal = isLocal,
hasValidPosition = hasValidPosition,
displayUnits = metricsState.displayUnits,
onAction = onAction,
)
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(vectorResource(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(vectorResource(LogsType.POSITIONS.icon), null, Modifier.size(18.dp)) },
)
}
}
}
}
PositionMap(node, distance)
LinkedCoordinatesItem(node, displayUnits)
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
) {
Icon(MeshtasticIcons.Compass, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(Res.string.open_compass),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@ -150,59 +105,3 @@ private fun PositionMap(node: Node, distance: String?) {
}
}
}
@Composable
private fun PositionActionButtons(
node: Node,
isLocal: Boolean,
hasValidPosition: Boolean,
displayUnits: Config.DisplayConfig.DisplayUnits,
onAction: (NodeDetailAction) -> Unit,
) {
if (isLocal && !hasValidPosition) return
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (!isLocal) {
Button(
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT),
shape = MaterialTheme.shapes.large,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Icon(MeshtasticIcons.LocationOn, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(Res.string.exchange_position),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
}
if (hasValidPosition) {
FilledTonalButton(
onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
modifier = if (isLocal) Modifier.fillMaxWidth() else Modifier.weight(COMPASS_BUTTON_WEIGHT),
shape = MaterialTheme.shapes.large,
) {
Icon(MeshtasticIcons.Compass, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(Res.string.open_compass),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View file

@ -61,8 +61,8 @@ import org.meshtastic.core.resources.userinfo
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
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.Config
private data class TelemetricFeature(
val titleRes: StringResource,
@ -72,21 +72,32 @@ private data class TelemetricFeature(
val isVisible: (Node) -> Boolean = { true },
val cooldownTimestamp: Long? = null,
val cooldownDuration: Long = COOL_DOWN_TIME_MS,
val content: @Composable ((Node) -> Unit)? = null,
val content: @Composable ((Node, (NodeDetailAction) -> Unit) -> Unit)? = null,
val hasContent: (Node) -> Boolean = { false },
)
@Composable
internal fun TelemetricActionsSection(
node: Node,
ourNode: Node?,
availableLogs: Set<LogsType>,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
metricsState: MetricsState,
displayUnits: Config.DisplayConfig.DisplayUnits,
isFahrenheit: Boolean,
onAction: (NodeDetailAction) -> Unit,
isLocal: Boolean = false,
) {
val features = rememberTelemetricFeatures(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal)
val features =
rememberTelemetricFeatures(
node,
ourNode,
lastTracerouteTime,
lastRequestNeighborsTime,
displayUnits,
isFahrenheit,
isLocal,
)
SectionCard(title = Res.string.telemetry) {
features
@ -111,83 +122,94 @@ internal fun TelemetricActionsSection(
@Composable
private fun rememberTelemetricFeatures(
node: Node,
ourNode: Node?,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
metricsState: MetricsState,
displayUnits: Config.DisplayConfig.DisplayUnits,
isFahrenheit: Boolean,
isLocal: Boolean,
): List<TelemetricFeature> = remember(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal) {
listOf(
TelemetricFeature(
titleRes = Res.string.userinfo,
icon = Res.drawable.ic_person,
requestAction = { NodeMenuAction.RequestUserInfo(it) },
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.TRACEROUTE.titleRes,
icon = LogsType.TRACEROUTE.icon,
requestAction = { NodeMenuAction.TraceRoute(it) },
logsType = LogsType.TRACEROUTE,
cooldownTimestamp = lastTracerouteTime,
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.NEIGHBOR_INFO.titleRes,
icon = LogsType.NEIGHBOR_INFO.icon,
requestAction = { NodeMenuAction.RequestNeighborInfo(it) },
logsType = LogsType.NEIGHBOR_INFO,
isVisible = { it.capabilities.canRequestNeighborInfo },
cooldownTimestamp = lastRequestNeighborsTime,
cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS,
),
TelemetricFeature(
titleRes = LogsType.SIGNAL.titleRes,
icon = LogsType.SIGNAL.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
logsType = LogsType.SIGNAL,
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.DEVICE.titleRes,
icon = LogsType.DEVICE.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) },
logsType = LogsType.DEVICE,
),
TelemetricFeature(
titleRes = LogsType.ENVIRONMENT.titleRes,
icon = Res.drawable.ic_thermostat,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) },
logsType = LogsType.ENVIRONMENT,
content = { EnvironmentMetrics(it, metricsState.displayUnits, metricsState.isFahrenheit) },
hasContent = { it.hasEnvironmentMetrics },
),
TelemetricFeature(
titleRes = Res.string.request_air_quality_metrics,
icon = Res.drawable.ic_air,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) },
),
TelemetricFeature(
titleRes = LogsType.POWER.titleRes,
icon = LogsType.POWER.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) },
logsType = LogsType.POWER,
content = { PowerMetrics(it) },
hasContent = { it.hasPowerMetrics },
),
TelemetricFeature(
titleRes = LogsType.HOST.titleRes,
icon = LogsType.HOST.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) },
logsType = LogsType.HOST,
),
TelemetricFeature(
titleRes = LogsType.PAX.titleRes,
icon = LogsType.PAX.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) },
logsType = LogsType.PAX,
),
)
}
): List<TelemetricFeature> =
remember(node, ourNode, lastTracerouteTime, lastRequestNeighborsTime, displayUnits, isFahrenheit, isLocal) {
listOf(
TelemetricFeature(
titleRes = Res.string.userinfo,
icon = Res.drawable.ic_person,
requestAction = { NodeMenuAction.RequestUserInfo(it) },
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.POSITIONS.titleRes,
icon = LogsType.POSITIONS.icon,
requestAction = if (isLocal) null else { n -> NodeMenuAction.RequestPosition(n) },
logsType = LogsType.POSITIONS,
content = { node, action -> PositionInlineContent(node, ourNode, displayUnits, action) },
hasContent = { it.latitude != 0.0 || it.longitude != 0.0 },
),
TelemetricFeature(
titleRes = LogsType.TRACEROUTE.titleRes,
icon = LogsType.TRACEROUTE.icon,
requestAction = { NodeMenuAction.TraceRoute(it) },
logsType = LogsType.TRACEROUTE,
cooldownTimestamp = lastTracerouteTime,
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.NEIGHBOR_INFO.titleRes,
icon = LogsType.NEIGHBOR_INFO.icon,
requestAction = { NodeMenuAction.RequestNeighborInfo(it) },
logsType = LogsType.NEIGHBOR_INFO,
isVisible = { it.capabilities.canRequestNeighborInfo },
cooldownTimestamp = lastRequestNeighborsTime,
cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS,
),
TelemetricFeature(
titleRes = LogsType.SIGNAL.titleRes,
icon = LogsType.SIGNAL.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
logsType = LogsType.SIGNAL,
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.DEVICE.titleRes,
icon = LogsType.DEVICE.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) },
logsType = LogsType.DEVICE,
),
TelemetricFeature(
titleRes = LogsType.ENVIRONMENT.titleRes,
icon = Res.drawable.ic_thermostat,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) },
logsType = LogsType.ENVIRONMENT,
content = { node, _ -> EnvironmentMetrics(node, displayUnits, isFahrenheit) },
hasContent = { it.hasEnvironmentMetrics },
),
TelemetricFeature(
titleRes = Res.string.request_air_quality_metrics,
icon = Res.drawable.ic_air,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) },
),
TelemetricFeature(
titleRes = LogsType.POWER.titleRes,
icon = LogsType.POWER.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) },
logsType = LogsType.POWER,
content = { node, _ -> PowerMetrics(node) },
hasContent = { it.hasPowerMetrics },
),
TelemetricFeature(
titleRes = LogsType.HOST.titleRes,
icon = LogsType.HOST.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) },
logsType = LogsType.HOST,
),
TelemetricFeature(
titleRes = LogsType.PAX.titleRes,
icon = LogsType.PAX.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) },
logsType = LogsType.PAX,
),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@ -273,7 +295,7 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean,
if (showContent) {
Column(modifier = Modifier.padding(start = 56.dp, end = 20.dp, bottom = 12.dp)) {
feature.content.invoke(node)
feature.content.invoke(node, onAction)
}
}
}

View file

@ -41,7 +41,6 @@ import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.NodeDetailAction
/**
@ -81,8 +80,8 @@ fun NodeDetailContent(
}
/**
* Scrollable list of node detail sections: identity, device actions, position, hardware details, notes, and
* administration.
* Scrollable list of node detail sections: identity, device actions (including telemetry and position), hardware
* details, notes, and administration.
*/
@Composable
fun NodeDetailList(
@ -105,15 +104,16 @@ fun NodeDetailList(
item {
DeviceActions(
node = node,
ourNode = ourNode,
lastTracerouteTime = uiState.lastTracerouteTime,
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
availableLogs = uiState.availableLogs,
onAction = onAction,
metricsState = uiState.metricsState,
displayUnits = uiState.metricsState.displayUnits,
isFahrenheit = uiState.metricsState.isFahrenheit,
isLocal = uiState.metricsState.isLocal,
)
}
item { PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction) }
if (uiState.metricsState.deviceHardware != null) {
item { DeviceDetailsSection(uiState.metricsState) }
}

View file

@ -0,0 +1,125 @@
/*
* Copyright (c) 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/>.
*/
@file:Suppress("TooManyFunctions", "MagicNumber")
package org.meshtastic.feature.node.detail
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
// ---------------------------------------------------------------------------
// Sample data for previews
// ---------------------------------------------------------------------------
private val previewData = NodePreviewParameterProvider()
// ---------------------------------------------------------------------------
// NodeDetailContent previews
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun NodeDetailContentRemotePreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
NodeDetailContent(
uiState =
NodeDetailUiState(
node = node,
ourNode = previewData.mickeyMouse.copy(num = 9999),
metricsState = MetricsState(isLocal = false, isManaged = false),
availableLogs =
setOf(
LogsType.DEVICE,
LogsType.POSITIONS,
LogsType.ENVIRONMENT,
LogsType.SIGNAL,
LogsType.TRACEROUTE,
),
),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}
}
@PreviewLightDark
@Composable
private fun NodeDetailContentLocalPreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
NodeDetailContent(
uiState =
NodeDetailUiState(
node = node,
ourNode = node,
metricsState = MetricsState(isLocal = true, isManaged = false),
availableLogs = setOf(LogsType.DEVICE, LogsType.POSITIONS),
),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}
}
@PreviewLightDark
@Composable
private fun NodeDetailContentLoadingPreview() {
AppTheme {
Surface {
NodeDetailContent(
uiState = NodeDetailUiState(),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}
}
@PreviewLightDark
@Composable
private fun NodeDetailContentMinimalPreview() {
val node = previewData.minnieMouse
AppTheme {
Surface {
NodeDetailContent(
uiState =
NodeDetailUiState(
node = node,
ourNode = previewData.mickeyMouse,
metricsState = MetricsState(isLocal = false, isManaged = true),
availableLogs = emptySet(),
),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}
}

View file

@ -186,7 +186,6 @@ constructor(
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)

View file

@ -19,9 +19,9 @@ package org.meshtastic.feature.node.metrics
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@ -31,16 +31,13 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
@ -65,16 +62,12 @@ import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.avg
import org.meshtastic.core.resources.collapse_chart
import org.meshtastic.core.resources.expand_chart
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.logs
import org.meshtastic.core.resources.max
import org.meshtastic.core.resources.min
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.BarChart
import org.meshtastic.core.ui.icon.Info
@ -137,6 +130,46 @@ fun GenericMetricChart(
)
}
/**
* Common scaffold for all metric chart composables. Provides:
* - A [Column] container with the supplied [modifier]
* - An empty-data guard (returns early when [isEmpty] is true)
* - A remembered [CartesianChartModelProducer] passed to [content]
* - A trailing [Legend] strip
*
* @param isEmpty Whether the chart data is empty when true, nothing is rendered.
* @param legendData Legend items shown below the chart.
* @param key Optional key for the [CartesianChartModelProducer] (e.g. a selected channel). Pass a different value to
* recreate the producer.
* @param hiddenSet Indices of hidden legend items (toggleable legend).
* @param onToggle Callback when a legend item is toggled; when null, a read-only legend is rendered.
* @param content Builder lambda receiving the [CartesianChartModelProducer] and a standard `Modifier.weight(1f)`
* suitable for the chart area.
*/
@Composable
fun MetricChartScaffold(
isEmpty: Boolean,
legendData: List<LegendData>,
modifier: Modifier = Modifier,
key: Any? = Unit,
hiddenSet: Set<Int> = emptySet(),
onToggle: ((Int) -> Unit)? = null,
content: @Composable ColumnScope.(CartesianChartModelProducer, Modifier) -> Unit,
) {
Column(modifier = modifier) {
if (isEmpty) return@Column
val modelProducer = remember(key) { CartesianChartModelProducer() }
val chartModifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp)
content(modelProducer, chartModifier)
Legend(
legendData = legendData,
modifier = Modifier.padding(top = 0.dp),
hiddenSet = hiddenSet,
onToggle = onToggle,
)
}
}
/**
* An adaptive layout for metric screens. Uses a split Row for wide screens (tablets/landscape) and a stacked Column for
* narrow screens (phones). When [isChartExpanded] is true, the card list is hidden and the chart fills the available
@ -164,7 +197,7 @@ fun AdaptiveMetricLayout(
if (isChartExpanded) {
Modifier.fillMaxWidth().weight(1f)
} else {
Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)
Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.45f)
},
)
AnimatedVisibility(visible = !isChartExpanded, enter = expandVertically(), exit = shrinkVertically()) {
@ -175,40 +208,6 @@ fun AdaptiveMetricLayout(
}
}
/**
* Displays a compact row of min/max/avg statistics for a metric. Intended to be placed between the chart controls and
* the chart itself.
*/
@Composable
fun MetricSummaryRow(values: List<Float>, label: String = "", modifier: Modifier = Modifier) {
if (values.isEmpty()) return
val minVal = values.min()
val maxVal = values.max()
val avgVal = values.average().toFloat()
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
SummaryChip(label = stringResource(Res.string.min), value = formatString("%.1f %s", minVal, label))
SummaryChip(label = stringResource(Res.string.avg), value = formatString("%.1f %s", avgVal, label))
SummaryChip(label = stringResource(Res.string.max), value = formatString("%.1f %s", maxVal, label))
}
}
@Composable
private fun SummaryChip(label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(text = value, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface)
}
}
/**
* A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and chart-to-list
* synchronisation.

View file

@ -29,10 +29,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.decoration.Decoration
import com.patrykandpatrick.vico.compose.cartesian.decoration.HorizontalLine
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.compose.cartesian.marker.DefaultCartesianMarker
import com.patrykandpatrick.vico.compose.cartesian.marker.LineCartesianLayerMarkerTarget
@ -249,10 +252,13 @@ object ChartStyling {
if (target is LineCartesianLayerMarkerTarget) {
target.points.forEachIndexed { pointIndex, point ->
if (pointIndex > 0) append(", ")
// Force alpha to 1f so text is readable even if the line is transparent/subtle
val color = point.color.copy(alpha = .8f)
val text = format(point.entry.y, color)
withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) { append(text) }
// Pass the opaque color to the format lambda so callers can match without alpha gymnastics.
// Apply 0.8 alpha only on the rendered text for readability.
val opaqueColor = point.color.copy(alpha = 1f)
val text = format(point.entry.y, opaqueColor)
withStyle(SpanStyle(color = opaqueColor.copy(alpha = .8f), fontWeight = FontWeight.Bold)) {
append(text)
}
}
}
}
@ -267,3 +273,25 @@ object ChartStyling {
fun rememberAxisLabel(color: Color = MaterialTheme.colorScheme.onSurfaceVariant): TextComponent =
rememberTextComponent(style = TextStyle(color = color, fontSize = 10.sp, fontWeight = FontWeight.Medium))
}
/**
* Creates a [LineCartesianLayer] only when [hasData] is true, returning null otherwise.
*
* Extracts the repeated `if (data.isNotEmpty()) rememberLineCartesianLayer(...) else null` pattern used in every metric
* chart composable.
*/
@Composable
fun rememberConditionalLayer(
hasData: Boolean,
lineProvider: LineCartesianLayer.LineProvider,
verticalAxisPosition: Axis.Position.Vertical,
rangeProvider: CartesianLayerRangeProvider? = null,
): LineCartesianLayer? = if (hasData) {
rememberLineCartesianLayer(
lineProvider = lineProvider,
verticalAxisPosition = verticalAxisPosition,
rangeProvider = rangeProvider ?: CartesianLayerRangeProvider.auto(),
)
} else {
null
}

View file

@ -48,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
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 com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
@ -56,6 +57,7 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.info
@ -63,29 +65,13 @@ import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.snr
import org.meshtastic.core.ui.icon.Info
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
import kotlin.time.Duration.Companion.days
object CommonCharts {
const val MS_PER_SEC = 1000L
const val MAX_PERCENT_VALUE = 100f
const val SCROLL_BIAS = 0.5f
/** Gets the Material 3 primary color with optional opacity adjustment. */
@Composable
fun getMaterial3PrimaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.primary.copy(alpha = alpha)
/** Gets the Material 3 secondary color with optional opacity adjustment. */
@Composable
fun getMaterial3SecondaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha)
/** Gets the Material 3 tertiary color with optional opacity adjustment. */
@Composable
fun getMaterial3TertiaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.tertiary.copy(alpha = alpha)
/** Gets the Material 3 error color with optional opacity adjustment. */
@Composable
fun getMaterial3ErrorColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.error.copy(alpha = alpha)
/**
* A dynamic [CartesianValueFormatter] that adjusts the time format based on the total data span
* ([CartesianRanges.xLength]).
@ -118,8 +104,6 @@ object CommonCharts {
}
}
fun formatDateTime(timestampMillis: Long): String = DateFormatter.formatDateTime(timestampMillis)
/**
* Shared bottom time axis used by all metric chart screens.
*
@ -142,7 +126,7 @@ data class LegendData(
val nameRes: StringResource,
val color: Color,
val isLine: Boolean = false,
val environmentMetric: Environment? = null,
val metricKey: Any? = null,
)
data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color)
@ -163,9 +147,9 @@ fun Legend(
onToggle: ((Int) -> Unit)? = null,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
legendData.forEachIndexed { index, data ->
val isVisible = index !in hiddenSet
@ -173,7 +157,7 @@ fun Legend(
FilterChip(
selected = isVisible,
onClick = { onToggle(index) },
label = { Text(stringResource(data.nameRes)) },
label = { Text(text = stringResource(data.nameRes), style = MaterialTheme.typography.labelSmall) },
leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) },
modifier = Modifier.padding(horizontal = 2.dp),
)
@ -262,7 +246,8 @@ fun MetricIndicator(color: Color, modifier: Modifier = Modifier) {
Box(modifier = modifier.size(8.dp).clip(CircleShape).background(color))
}
@Suppress("UnusedPrivateMember") // Compose preview
@PreviewLightDark
@Suppress("unused") // Compose preview
@Composable
private fun LegendPreview() {
val data =
@ -270,10 +255,12 @@ private fun LegendPreview() {
LegendData(nameRes = Res.string.rssi, color = Color.Red, isLine = true),
LegendData(nameRes = Res.string.snr, color = Color.Green, isLine = true),
)
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Read-only legend
Legend(legendData = data)
// Toggleable legend
Legend(legendData = data, hiddenSet = setOf(1), onToggle = {})
AppTheme {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Read-only legend
Legend(legendData = data)
// Toggleable legend
Legend(legendData = data, hiddenSet = setOf(1), onToggle = {})
}
}
}

View file

@ -15,11 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -32,9 +31,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -49,21 +45,22 @@ 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.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_util_definition
@ -84,7 +81,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Cyan
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
private enum class Device(val color: Color) {
@ -106,20 +102,10 @@ private enum class Device(val color: Color) {
private val LEGEND_DATA =
listOf(
LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true, environmentMetric = null),
LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true, environmentMetric = null),
LegendData(
nameRes = Res.string.channel_utilization,
color = Device.CH_UTIL.color,
isLine = true,
environmentMetric = null,
),
LegendData(
nameRes = Res.string.air_utilization,
color = Device.AIR_UTIL.color,
isLine = true,
environmentMetric = null,
),
LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true),
LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true),
LegendData(nameRes = Res.string.channel_utilization, color = Device.CH_UTIL.color, isLine = true),
LegendData(nameRes = Res.string.air_utilization, color = Device.AIR_UTIL.color, isLine = true),
)
@Suppress("LongMethod")
@ -188,10 +174,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
onTimeFrameSelected = viewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
if (hasBattery) {
val batteryValues = remember(data) { data.mapNotNull { it.device_metrics?.battery_level?.toFloat() } }
MetricSummaryRow(values = batteryValues, label = "%")
}
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
DeviceMetricsChart(
@ -219,7 +201,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
@ -228,10 +209,10 @@ private fun DeviceMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (telemetries.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(isEmpty = telemetries.isEmpty(), legendData = legendData, modifier = modifier) {
modelProducer,
chartModifier,
->
val batteryColor = Device.BATTERY.color
val voltageColor = Device.VOLTAGE.color
val chUtilColor = Device.CH_UTIL.color
@ -247,7 +228,7 @@ private fun DeviceMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
when (color) {
batteryColor -> formatString(percentValueTemplate, batteryLabel, value)
voltageColor -> formatString(voltageValueTemplate, voltageLabel, value)
chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value)
@ -322,28 +303,20 @@ private fun DeviceMetricsChart(
}
val leftLayer =
if (leftLayerSeriesStyles.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0),
)
} else {
null
}
rememberConditionalLayer(
hasData = leftLayerSeriesStyles.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0),
)
val rightLayer =
if (voltageData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createGradientLine(lineColor = voltageColor),
),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
rememberConditionalLayer(
hasData = voltageData.isNotEmpty(),
lineProvider =
LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(lineColor = voltageColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
val layers = remember(leftLayer, rightLayer) { listOfNotNull(leftLayer, rightLayer) }
@ -356,7 +329,7 @@ private fun DeviceMetricsChart(
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
modifier = chartModifier,
layers = layers,
startAxis =
if (leftLayer != null) {
@ -384,14 +357,12 @@ private fun DeviceMetricsChart(
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = legendData, modifier = Modifier.padding(top = 0.dp))
}
}
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@PreviewLightDark
@Suppress("detekt:MagicNumber") // Compose preview with fake data
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsChartPreview() {
val now = nowSeconds.toInt()
val telemetries =
@ -422,7 +393,6 @@ private fun DeviceMetricsChartPreview() {
@Composable
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val deviceMetrics = telemetry.device_metrics
val time = telemetry.time.toLong() * MS_PER_SEC
@ -431,101 +401,75 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
val uptimeLabel = stringResource(Res.string.uptime)
val percentValueTemplate = stringResource(Res.string.device_metrics_percent_value)
val labelValueTemplate = stringResource(Res.string.device_metrics_label_value)
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface(color = Color.Transparent) {
SelectionContainer {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
/* Time, Battery, and Voltage */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = CommonCharts.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
/* Time, Battery, and Voltage */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceMetrics?.battery_level != null) {
MetricIndicator(Device.BATTERY.color)
Spacer(Modifier.width(4.dp))
}
if (deviceMetrics?.voltage != null) {
MetricIndicator(Device.VOLTAGE.color)
Spacer(Modifier.width(8.dp))
}
MaterialBatteryInfo(
level = deviceMetrics?.battery_level ?: 0,
voltage = deviceMetrics?.voltage ?: 0f,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceMetrics?.battery_level != null) {
MetricIndicator(Device.BATTERY.color)
Spacer(Modifier.width(4.dp))
}
if (deviceMetrics?.voltage != null) {
MetricIndicator(Device.VOLTAGE.color)
Spacer(Modifier.width(8.dp))
}
MaterialBatteryInfo(
level = deviceMetrics?.battery_level ?: 0,
voltage = deviceMetrics?.voltage ?: 0f,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
/* Channel Utilization and Air Utilization Tx */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceMetrics?.channel_utilization != null) {
MetricIndicator(Device.CH_UTIL.color)
Spacer(Modifier.width(4.dp))
Text(
text =
formatString(
percentValueTemplate,
channelUtilizationLabel,
deviceMetrics.channel_utilization ?: 0f,
),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Spacer(Modifier.width(12.dp))
}
if (deviceMetrics?.air_util_tx != null) {
MetricIndicator(Device.AIR_UTIL.color)
Spacer(Modifier.width(4.dp))
Text(
text =
formatString(
percentValueTemplate,
airUtilizationLabel,
deviceMetrics.air_util_tx ?: 0f,
),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
Text(
/* Channel Utilization and Air Utilization Tx */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceMetrics?.channel_utilization != null) {
MetricValueRow(
color = Device.CH_UTIL.color,
text =
formatString(
labelValueTemplate,
uptimeLabel,
formatUptime(deviceMetrics?.uptime_seconds ?: 0),
percentValueTemplate,
channelUtilizationLabel,
deviceMetrics.channel_utilization ?: 0f,
),
)
Spacer(Modifier.width(12.dp))
}
if (deviceMetrics?.air_util_tx != null) {
MetricValueRow(
color = Device.AIR_UTIL.color,
text =
formatString(
percentValueTemplate,
airUtilizationLabel,
deviceMetrics.air_util_tx ?: 0f,
),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
Text(
text =
formatString(labelValueTemplate, uptimeLabel, formatUptime(deviceMetrics?.uptime_seconds ?: 0)),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@PreviewLightDark
@Suppress("detekt:MagicNumber") // Compose preview with fake data
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsCardPreview() {
val now = nowSeconds.toInt()
val telemetry =
@ -543,9 +487,9 @@ private fun DeviceMetricsCardPreview() {
AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) }
}
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@PreviewLightDark
@Suppress("detekt:MagicNumber") // Compose preview with fake data
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsScreenPreview() {
val now = nowSeconds.toInt()
val telemetries =

View file

@ -57,52 +57,42 @@ private val LEGEND_DATA_1 =
nameRes = Res.string.temperature,
color = Environment.TEMPERATURE.color,
isLine = true,
environmentMetric = Environment.TEMPERATURE,
metricKey = Environment.TEMPERATURE,
),
LegendData(
nameRes = Res.string.humidity,
color = Environment.HUMIDITY.color,
isLine = true,
environmentMetric = Environment.HUMIDITY,
metricKey = Environment.HUMIDITY,
),
)
private val LEGEND_DATA_2 =
listOf(
LegendData(
nameRes = Res.string.iaq,
color = Environment.IAQ.color,
isLine = true,
environmentMetric = Environment.IAQ,
),
LegendData(nameRes = Res.string.iaq, color = Environment.IAQ.color, isLine = true, metricKey = Environment.IAQ),
LegendData(
nameRes = Res.string.baro_pressure,
color = Environment.BAROMETRIC_PRESSURE.color,
isLine = true,
environmentMetric = Environment.BAROMETRIC_PRESSURE,
),
LegendData(
nameRes = Res.string.lux,
color = Environment.LUX.color,
isLine = true,
environmentMetric = Environment.LUX,
metricKey = Environment.BAROMETRIC_PRESSURE,
),
LegendData(nameRes = Res.string.lux, color = Environment.LUX.color, isLine = true, metricKey = Environment.LUX),
LegendData(
nameRes = Res.string.uv_lux,
color = Environment.UV_LUX.color,
isLine = true,
environmentMetric = Environment.UV_LUX,
metricKey = Environment.UV_LUX,
),
LegendData(
nameRes = Res.string.wind_speed,
color = Environment.WIND_SPEED.color,
isLine = true,
environmentMetric = Environment.WIND_SPEED,
metricKey = Environment.WIND_SPEED,
),
LegendData(
nameRes = Res.string.radiation,
color = Environment.RADIATION.color,
isLine = true,
environmentMetric = Environment.RADIATION,
metricKey = Environment.RADIATION,
),
)
@ -112,13 +102,13 @@ private val LEGEND_DATA_3 =
nameRes = Res.string.soil_temperature,
color = Environment.SOIL_TEMPERATURE.color,
isLine = true,
environmentMetric = Environment.SOIL_TEMPERATURE,
metricKey = Environment.SOIL_TEMPERATURE,
),
LegendData(
nameRes = Res.string.soil_moisture,
color = Environment.SOIL_MOISTURE.color,
isLine = true,
environmentMetric = Environment.SOIL_MOISTURE,
metricKey = Environment.SOIL_MOISTURE,
),
)
@ -143,14 +133,14 @@ fun EnvironmentMetricsChart(
val allLegendData =
(LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter {
graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0]
graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0]
}
// Legend toggle state: tracks indices into allLegendData that are hidden
var hiddenIndices by remember { mutableStateOf(emptySet<Int>()) }
val hiddenMetrics =
remember(hiddenIndices, allLegendData) {
hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.environmentMetric }.toSet()
hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet()
}
val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) }
@ -216,7 +206,7 @@ fun EnvironmentMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
val label = colorToLabel[color.copy(alpha = 1f)] ?: ""
val label = colorToLabel[color] ?: ""
formatString("%s: %.1f", label, value)
},
)

View file

@ -15,11 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -31,26 +30,24 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.env_metrics_log
@ -73,7 +70,7 @@ import org.meshtastic.core.resources.wind_lull
import org.meshtastic.core.resources.wind_speed
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Telemetry
@Composable
@ -100,14 +97,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
onTimeFrameSelected = viewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
val tempValues =
remember(filteredTelemetries) {
filteredTelemetries.mapNotNull { it.environment_metrics?.temperature?.takeIf { t -> !t.isNaN() } }
}
if (tempValues.isNotEmpty()) {
val unit = if (state.isFahrenheit) "°F" else "°C"
MetricSummaryRow(values = tempValues, label = unit)
}
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
EnvironmentMetricsChart(
@ -135,7 +124,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun TemperatureDisplay(
envMetrics: org.meshtastic.proto.EnvironmentMetrics,
environmentDisplayFahrenheit: Boolean,
@ -157,7 +145,6 @@ private fun TemperatureDisplay(
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasHumidity = envMetrics.relative_humidity?.let { !it.isNaN() } == true
val hasPressure = envMetrics.barometric_pressure?.let { !it.isNaN() && it > 0 } == true
@ -198,7 +185,6 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun SoilMetricsDisplay(
envMetrics: org.meshtastic.proto.EnvironmentMetrics,
environmentDisplayFahrenheit: Boolean,
@ -251,7 +237,6 @@ private fun SoilMetricsDisplay(
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasLux = envMetrics.lux != null && !envMetrics.lux!!.isNaN()
val hasUvLux = envMetrics.uv_lux != null && !envMetrics.uv_lux!!.isNaN()
@ -287,7 +272,6 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage!!.isNaN()
val hasCurrent = envMetrics.current != null && !envMetrics.current!!.isNaN()
@ -315,7 +299,6 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val iaqValue = envMetrics.iaq
val gasResistance = envMetrics.gas_resistance
@ -351,7 +334,6 @@ private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
envMetrics.radiation?.let { radiation ->
if (!radiation.isNaN() && radiation > 0f) {
@ -371,7 +353,6 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasSpeed = envMetrics.wind_speed != null && !envMetrics.wind_speed!!.isNaN()
val hasGust = envMetrics.wind_gust != null && !envMetrics.wind_gust!!.isNaN()
@ -386,7 +367,6 @@ private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -414,7 +394,6 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, hasGust: Boolean, hasLull: Boolean) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (hasGust) {
@ -435,7 +414,6 @@ private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics,
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val has1h = envMetrics.rainfall_1h != null && !envMetrics.rainfall_1h!!.isNaN()
val has24h = envMetrics.rainfall_24h != null && !envMetrics.rainfall_24h!!.isNaN()
@ -462,34 +440,18 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun EnvironmentMetricsCard(
telemetry: Telemetry,
environmentDisplayFahrenheit: Boolean,
isSelected: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface(color = Color.Transparent) {
SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) }
}
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit)
}
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics()
val time = telemetry.time.toLong() * MS_PER_SEC
@ -497,7 +459,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
/* Time and Temperature */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = CommonCharts.formatDateTime(time),
text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
@ -521,9 +483,9 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
}
}
@Suppress("MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@PreviewLightDark
@Suppress("MagicNumber") // Compose preview with fake data
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PreviewEnvironmentMetricsContent() {
val fakeEnvMetrics =
org.meshtastic.proto.EnvironmentMetrics(
@ -547,7 +509,5 @@ private fun PreviewEnvironmentMetricsContent() {
rainfall_24h = 12.3f,
)
val fakeTelemetry = Telemetry(time = nowSeconds.toInt(), environment_metrics = fakeEnvMetrics)
MaterialTheme {
Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) }
}
AppTheme { Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) } }
}

View file

@ -18,22 +18,17 @@
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.free_memory
@ -104,11 +99,10 @@ internal fun HostMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (data.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(isEmpty = data.isEmpty(), legendData = HOST_METRICS_LEGEND_DATA, modifier = modifier) {
modelProducer,
chartModifier,
->
val load1Data = remember(data) { data.filter { it.host_metrics?.load1 != null && it.host_metrics!!.load1 > 0 } }
val load5Data = remember(data) { data.filter { it.host_metrics?.load5 != null && it.host_metrics!!.load5 > 0 } }
val load15Data =
@ -157,7 +151,7 @@ internal fun HostMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
when (color) {
load1Color -> formatString("L1: %.2f", value)
load5Color -> formatString("L5: %.2f", value)
load15Color -> formatString("L15: %.2f", value)
@ -167,39 +161,33 @@ internal fun HostMetricsChart(
)
val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty()
val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null
val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null
val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null
val loadStyles = listOfNotNull(load1Style, load5Style, load15Style)
val loadLayer =
if (hasLoad) {
val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null
val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null
val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null
val styles = listOfNotNull(load1Style, load5Style, load15Style)
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(styles),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
} else {
null
}
rememberConditionalLayer(
hasData = hasLoad,
lineProvider = LineCartesianLayer.LineProvider.series(loadStyles),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
val memLayer =
if (memData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(memColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
} else {
null
}
rememberConditionalLayer(
hasData = memData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(memColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
val layers = remember(loadLayer, memLayer) { listOfNotNull(loadLayer, memLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
modifier = chartModifier,
layers = layers,
startAxis =
if (hasLoad) {
@ -226,7 +214,5 @@ internal fun HostMetricsChart(
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = HOST_METRICS_LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}

View file

@ -14,9 +14,13 @@
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@ -27,6 +31,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
@ -38,6 +43,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -49,7 +55,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
/** Shared metric log/list UI components used by TracerouteLog, NeighborInfoLog, HostMetricsLog, and PositionLog. */
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp),
@ -99,3 +104,45 @@ fun DeleteItem(onClick: () -> Unit) {
},
)
}
/**
* A selectable [Card] for metric log items. Provides consistent selection styling (primary border + primaryContainer
* background) and text selection support across all metric screens.
*/
@Composable
fun SelectableMetricCard(
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Card(
modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
SelectionContainer { content() }
}
}
/** A compact row displaying a colored [MetricIndicator] dot/line followed by a text value. */
@Composable
fun MetricValueRow(color: Color, text: String, modifier: Modifier = Modifier) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
MetricIndicator(color)
Spacer(Modifier.width(4.dp))
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}

View file

@ -47,7 +47,9 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.GeoConstants
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
@ -61,7 +63,6 @@ import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
@ -333,12 +334,12 @@ open class MetricsViewModel(
.toLocalDateTime(TimeZone.currentSystemDefault())
val rxDateTime = "\"${localDateTime.date}\",\"${localDateTime.time}\""
val latitude = (position.latitude_i ?: 0) * 1e-7
val longitude = (position.longitude_i ?: 0) * 1e-7
val latitude = (position.latitude_i ?: 0) * GeoConstants.DEG_D
val longitude = (position.longitude_i ?: 0) * GeoConstants.DEG_D
val altitude = position.altitude
val satsInView = position.sats_in_view
val speed = position.ground_speed
val heading = formatString("%.2f", (position.ground_track ?: 0) * 1e-5)
val heading = formatString("%.2f", (position.ground_track ?: 0) * GeoConstants.HEADING_DEG)
sink.writeUtf8(
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n",

View file

@ -14,10 +14,10 @@
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -28,8 +28,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -46,7 +44,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
@ -57,12 +54,19 @@ import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ble_devices
import org.meshtastic.core.resources.no_pax_metrics_logs
import org.meshtastic.core.resources.pax
import org.meshtastic.core.resources.pax_ble_format
import org.meshtastic.core.resources.pax_ble_marker
import org.meshtastic.core.resources.pax_metrics_log
import org.meshtastic.core.resources.pax_total_format
import org.meshtastic.core.resources.pax_total_marker
import org.meshtastic.core.resources.pax_wifi_format
import org.meshtastic.core.resources.pax_wifi_marker
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.wifi_devices
import org.meshtastic.core.ui.component.IconInfo
@ -80,14 +84,13 @@ private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
private val LEGEND_DATA =
listOf(
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null),
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null),
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null),
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color),
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color),
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color),
)
@Suppress("LongMethod")
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PaxMetricsChart(
modifier: Modifier = Modifier,
totalSeries: List<Pair<Int, Int>>,
@ -97,10 +100,10 @@ private fun PaxMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (totalSeries.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(isEmpty = totalSeries.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) {
modelProducer,
chartModifier,
->
val paxColor = PaxSeries.PAX.color
val bleColor = PaxSeries.BLE.color
val wifiColor = PaxSeries.WIFI.color
@ -116,22 +119,26 @@ private fun PaxMetricsChart(
}
val axisLabel = ChartStyling.rememberAxisLabel()
val bleMarkerTemplate = stringResource(Res.string.pax_ble_marker)
val wifiMarkerTemplate = stringResource(Res.string.pax_wifi_marker)
val paxMarkerTemplate = stringResource(Res.string.pax_total_marker)
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
bleColor -> formatString("BLE: %.0f", value)
wifiColor -> formatString("WiFi: %.0f", value)
paxColor -> formatString("PAX: %.0f", value)
else -> formatString("%.0f", value)
val formatted = formatString("%.0f", value)
when (color) {
bleColor -> bleMarkerTemplate.replace("%1\$s", formatted)
wifiColor -> wifiMarkerTemplate.replace("%1\$s", formatted)
paxColor -> paxMarkerTemplate.replace("%1\$s", formatted)
else -> formatted
}
},
)
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
modifier = chartModifier,
layers =
listOf(
rememberLineCartesianLayer(
@ -151,8 +158,6 @@ private fun PaxMetricsChart(
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 4.dp))
}
}
@ -169,7 +174,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
remember(paxMetrics) {
paxMetrics
.map {
val t = (it.first.received_date / CommonCharts.MS_PER_SEC).toInt()
val t = (it.first.received_date / MS_PER_SEC).toInt()
Triple(t, it.second.ble, it.second.wifi)
}
.sortedBy { it.first }
@ -184,7 +189,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
titleRes = Res.string.pax_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = paxMetrics,
timeProvider = { (it.first.received_date / CommonCharts.MS_PER_SEC).toDouble() },
timeProvider = { (it.first.received_date / MS_PER_SEC).toDouble() },
onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) },
controlPart = {
TimeFrameSelector(
@ -224,8 +229,8 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
PaxMetricsItem(
log = log,
pax = pax,
isSelected = (log.received_date / CommonCharts.MS_PER_SEC).toDouble() == selectedX,
onClick = { onCardClick((log.received_date / CommonCharts.MS_PER_SEC).toDouble()) },
isSelected = (log.received_date / MS_PER_SEC).toDouble() == selectedX,
onClick = { onCardClick((log.received_date / MS_PER_SEC).toDouble()) },
)
}
}
@ -250,21 +255,8 @@ fun PaxcountInfo(
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = DateFormatter.formatDateTime(log.received_date),
@ -278,17 +270,20 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClic
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
MetricIndicator(PaxSeries.PAX.color)
Spacer(Modifier.width(4.dp))
Text(text = "PAX: ${pax.ble + pax.wifi}", style = MaterialTheme.typography.bodyLarge)
MetricValueRow(
color = PaxSeries.PAX.color,
text = stringResource(Res.string.pax_total_format, pax.ble + pax.wifi),
)
Spacer(Modifier.width(8.dp))
MetricIndicator(PaxSeries.BLE.color)
Spacer(Modifier.width(4.dp))
Text(text = "B:${pax.ble}", style = MaterialTheme.typography.bodyLarge)
MetricValueRow(
color = PaxSeries.BLE.color,
text = stringResource(Res.string.pax_ble_format, pax.ble),
)
Spacer(Modifier.width(8.dp))
MetricIndicator(PaxSeries.WIFI.color)
Spacer(Modifier.width(4.dp))
Text(text = "W:${pax.wifi}", style = MaterialTheme.typography.bodyLarge)
MetricValueRow(
color = PaxSeries.WIFI.color,
text = stringResource(Res.string.pax_wifi_format, pax.wifi),
)
}
Text(

View file

@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
@ -79,9 +81,6 @@ fun PositionLogHeader(compactWidth: Boolean) {
}
}
const val DEG_D = 1e-7
const val HEADING_DEG = 1e-5
@Composable
fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) {
Row(

View file

@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -44,12 +45,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.collapse_chart
import org.meshtastic.core.resources.expand_chart
import org.meshtastic.core.resources.logs
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.save
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.BarChart
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.List
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Save
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
@Composable
private fun ActionButtons(
@ -92,16 +100,32 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
org.meshtastic.core.ui.util.rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri) }
var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) }
var isMapExpanded by remember { mutableStateOf(false) }
val trackMap = LocalNodeTrackMapProvider.current
val destNum = state.node?.num ?: 0
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle =
stringResource(Res.string.position_log) +
" (${state.positionLogs.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
IconButton(onClick = { isMapExpanded = !isMapExpanded }) {
Icon(
imageVector = if (isMapExpanded) MeshtasticIcons.List else MeshtasticIcons.BarChart,
contentDescription =
stringResource(
if (isMapExpanded) Res.string.collapse_chart else Res.string.expand_chart,
),
)
}
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestPosition() }) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
@ -112,30 +136,38 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
)
},
) { innerPadding ->
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
val compactWidth = maxWidth < 600.dp
Column {
val textStyle =
if (compactWidth) {
MaterialTheme.typography.bodySmall
} else {
LocalTextStyle.current
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
PositionLogHeader(compactWidth)
PositionList(compactWidth, state.positionLogs, state.displayUnits)
}
Column(modifier = Modifier.padding(innerPadding)) {
AdaptiveMetricLayout(
isChartExpanded = isMapExpanded,
chartPart = { modifier -> trackMap(destNum, state.positionLogs, modifier) },
listPart = { modifier ->
BoxWithConstraints(modifier = modifier) {
val compactWidth = maxWidth < 600.dp
Column {
val textStyle =
if (compactWidth) {
MaterialTheme.typography.bodySmall
} else {
LocalTextStyle.current
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
PositionLogHeader(compactWidth)
PositionList(compactWidth, state.positionLogs, state.displayUnits)
}
ActionButtons(
clearButtonEnabled = clearButtonEnabled,
onClear = {
clearButtonEnabled = false
viewModel.clearPosition()
},
saveButtonEnabled = state.hasPositionLogs(),
onSave = { exportPositionLauncher("position.csv", "text/csv") },
)
}
ActionButtons(
clearButtonEnabled = clearButtonEnabled,
onClear = {
clearButtonEnabled = false
viewModel.clearPosition()
},
saveButtonEnabled = state.hasPositionLogs(),
onSave = { exportPositionLauncher("position.csv", "text/csv") },
)
}
}
},
)
}
}
}

View file

@ -15,11 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -29,17 +28,12 @@ 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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -47,7 +41,6 @@ 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.TextStyle
@ -57,14 +50,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
import org.meshtastic.core.resources.channel_2
@ -79,7 +72,6 @@ import org.meshtastic.core.resources.power_metrics_log
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
private enum class PowerMetric(val color: Color) {
@ -100,18 +92,8 @@ private enum class PowerChannel(val strRes: StringResource) {
private val LEGEND_DATA =
listOf(
LegendData(
nameRes = Res.string.current,
color = PowerMetric.CURRENT.color,
isLine = true,
environmentMetric = null,
),
LegendData(
nameRes = Res.string.voltage,
color = PowerMetric.VOLTAGE.color,
isLine = true,
environmentMetric = null,
),
LegendData(nameRes = Res.string.current, color = PowerMetric.CURRENT.color, isLine = true),
LegendData(nameRes = Res.string.voltage, color = PowerMetric.VOLTAGE.color, isLine = true),
)
@Suppress("LongMethod")
@ -187,7 +169,6 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@Suppress("LongMethod")
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
@ -196,17 +177,19 @@ private fun PowerMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (telemetries.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(
isEmpty = telemetries.isEmpty(),
legendData = LEGEND_DATA,
modifier = modifier,
key = selectedChannel,
) { modelProducer, chartModifier ->
val currentColor = PowerMetric.CURRENT.color
val voltageColor = PowerMetric.VOLTAGE.color
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
when (color) {
currentColor -> formatString("Current: %.0f mA", value)
voltageColor -> formatString("Voltage: %.1f V", value)
else -> formatString("%.1f", value)
@ -223,7 +206,7 @@ private fun PowerMetricsChart(
telemetries.filter { !retrieveVoltage(selectedChannel, it).isNaN() }
}
LaunchedEffect(currentData, voltageData) {
LaunchedEffect(selectedChannel, currentData, voltageData) {
modelProducer.runTransaction {
if (currentData.isNotEmpty()) {
lineSeries {
@ -245,32 +228,25 @@ private fun PowerMetricsChart(
}
val currentLayer =
if (currentData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(currentColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
)
} else {
null
}
rememberConditionalLayer(
hasData = currentData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(currentColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
)
val voltageLayer =
if (voltageData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(voltageColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
rememberConditionalLayer(
hasData = voltageData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(voltageColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
val layers = remember(currentLayer, voltageLayer) { listOfNotNull(currentLayer, voltageLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
modifier = chartModifier,
layers = layers,
startAxis =
if (currentData.isNotEmpty()) {
@ -297,50 +273,31 @@ private fun PowerMetricsChart(
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}
@Composable
@Suppress("CyclomaticComplexMethod", "LongMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val time = telemetry.time.toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface {
SelectionContainer {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
/* Time */
Row {
Text(
text = CommonCharts.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
}
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
/* Time */
Row {
Text(
text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
val pm = telemetry.power_metrics
if (pm != null) {
PowerChannelsRow1(pm)
PowerChannelsExtraRows(pm)
}
}
val pm = telemetry.power_metrics
if (pm != null) {
PowerChannelsRow1(pm)
PowerChannelsExtraRows(pm)
}
}
}
@ -348,7 +305,6 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick:
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerChannelsRow1(pm: org.meshtastic.proto.PowerMetrics) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
if (pm.ch1_current != null || pm.ch1_voltage != null) {
@ -365,7 +321,6 @@ private fun PowerChannelsRow1(pm: org.meshtastic.proto.PowerMetrics) {
@Composable
@Suppress("CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerChannelsExtraRows(pm: org.meshtastic.proto.PowerMetrics) {
val hasCh456 =
hasChannelData(pm.ch4_voltage, pm.ch4_current) ||
@ -403,7 +358,6 @@ private fun PowerChannelsExtraRows(pm: org.meshtastic.proto.PowerMetrics) {
private fun hasChannelData(voltage: Float?, current: Float?): Boolean = voltage != null || current != null
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current: Float) {
Column {
Text(
@ -411,30 +365,13 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(PowerMetric.VOLTAGE.color)
Spacer(Modifier.width(4.dp))
Text(
text = formatString("%.2fV", voltage),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(PowerMetric.CURRENT.color)
Spacer(Modifier.width(4.dp))
Text(
text = formatString("%.1fmA", current),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
MetricValueRow(color = PowerMetric.VOLTAGE.color, text = formatString("%.2fV", voltage))
MetricValueRow(color = PowerMetric.CURRENT.color, text = formatString("%.1fmA", current))
}
}
/** Retrieves the appropriate voltage depending on `channelSelected`. */
@Suppress("CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
PowerChannel.ONE -> telemetry.power_metrics?.ch1_voltage ?: Float.NaN
PowerChannel.TWO -> telemetry.power_metrics?.ch2_voltage ?: Float.NaN
@ -448,7 +385,6 @@ private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry)
/** Retrieves the appropriate current depending on `channelSelected`. */
@Suppress("CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
PowerChannel.ONE -> telemetry.power_metrics?.ch1_current ?: Float.NaN
PowerChannel.TWO -> telemetry.power_metrics?.ch2_current ?: Float.NaN

View file

@ -14,10 +14,10 @@
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -31,12 +31,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -51,12 +47,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.rssi_definition
@ -66,7 +62,6 @@ import org.meshtastic.core.resources.snr_definition
import org.meshtastic.core.ui.component.LoraSignalIndicator
import org.meshtastic.core.ui.theme.GraphColors.Blue
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.MeshPacket
private enum class SignalMetric(val color: Color) {
@ -76,8 +71,8 @@ private enum class SignalMetric(val color: Color) {
private val LEGEND_DATA =
listOf(
LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color, environmentMetric = null),
LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color, environmentMetric = null),
LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color),
LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color),
)
@Suppress("LongMethod")
@ -134,7 +129,6 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun SignalMetricsChart(
modifier: Modifier = Modifier,
meshPackets: List<MeshPacket>,
@ -142,10 +136,10 @@ private fun SignalMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (meshPackets.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(isEmpty = meshPackets.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) {
modelProducer,
chartModifier,
->
val rssiColor = SignalMetric.RSSI.color
val snrColor = SignalMetric.SNR.color
@ -168,7 +162,7 @@ private fun SignalMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
if (color.copy(alpha = 1f) == rssiColor) {
if (color == rssiColor) {
formatString("RSSI: %.0f dBm", value)
} else {
formatString("SNR: %.1f dB", value)
@ -177,31 +171,25 @@ private fun SignalMetricsChart(
)
val rssiLayer =
if (rssiData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
)
} else {
null
}
rememberConditionalLayer(
hasData = rssiData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
)
val snrLayer =
if (snrData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(snrColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
rememberConditionalLayer(
hasData = snrData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(snrColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
val layers = remember(rssiLayer, snrLayer) { listOfNotNull(rssiLayer, snrLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
modifier = chartModifier,
layers = layers,
startAxis =
if (rssiData.isNotEmpty()) {
@ -228,70 +216,47 @@ private fun SignalMetricsChart(
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
val time = meshPacket.rx_time.toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface(color = Color.Transparent) {
SelectionContainer {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
/* Data */
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(12.dp)) {
/* Time */
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = CommonCharts.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(8.dp))
/* SNR and RSSI */
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(SignalMetric.RSSI.color)
Spacer(Modifier.width(4.dp))
Text(
text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()),
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.width(12.dp))
MetricIndicator(SignalMetric.SNR.color)
Spacer(Modifier.width(4.dp))
Text(
text = formatString("%.1f dB", meshPacket.rx_snr),
style = MaterialTheme.typography.labelLarge,
)
}
}
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
/* Data */
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(12.dp)) {
/* Time */
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
}
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi)
Spacer(modifier = Modifier.height(8.dp))
/* SNR and RSSI */
Row(verticalAlignment = Alignment.CenterVertically) {
MetricValueRow(
color = SignalMetric.RSSI.color,
text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()),
)
Spacer(Modifier.width(12.dp))
MetricValueRow(
color = SignalMetric.SNR.color,
text = formatString("%.1f dB", meshPacket.rx_snr),
)
}
}
}
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi)
}
}
}
}

View file

@ -18,22 +18,17 @@
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.fullRouteDiscovery
@ -151,11 +146,10 @@ internal fun TracerouteMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (points.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(isEmpty = points.isEmpty(), legendData = TRACEROUTE_LEGEND_DATA, modifier = modifier) {
modelProducer,
chartModifier,
->
val forwardData = remember(points) { points.filter { it.forwardHops != null } }
val returnData = remember(points) { points.filter { it.returnHops != null } }
val rttData = remember(points) { points.filter { it.roundTripSeconds != null } }
@ -184,7 +178,7 @@ internal fun TracerouteMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
when (color) {
forwardColor -> formatString("Fwd: %.0f hops", value)
returnColor -> formatString("Ret: %.0f hops", value)
else -> formatString("RTT: %.1f s", value)
@ -193,36 +187,27 @@ internal fun TracerouteMetricsChart(
)
val forwardLayer =
if (forwardData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
} else {
null
}
rememberConditionalLayer(
hasData = forwardData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
val returnLayer =
if (returnData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
} else {
null
}
rememberConditionalLayer(
hasData = returnData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
val rttLayer =
if (rttData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(rttColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
rememberConditionalLayer(
hasData = rttData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(rttColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
val layers =
remember(forwardLayer, returnLayer, rttLayer) { listOfNotNull(forwardLayer, returnLayer, rttLayer) }
@ -230,7 +215,7 @@ internal fun TracerouteMetricsChart(
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
modifier = chartModifier,
layers = layers,
startAxis =
if (forwardData.isNotEmpty() || returnData.isNotEmpty()) {
@ -257,7 +242,5 @@ internal fun TracerouteMetricsChart(
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = TRACEROUTE_LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}

View file

@ -58,6 +58,7 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
@ -83,7 +84,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateTraceroute
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.proto.RouteDiscovery

View file

@ -27,7 +27,6 @@ import org.meshtastic.core.resources.host_metrics_log
import org.meshtastic.core.resources.ic_charging_station
import org.meshtastic.core.resources.ic_groups
import org.meshtastic.core.resources.ic_location_on
import org.meshtastic.core.resources.ic_map
import org.meshtastic.core.resources.ic_memory
import org.meshtastic.core.resources.ic_people
import org.meshtastic.core.resources.ic_power
@ -35,7 +34,6 @@ import org.meshtastic.core.resources.ic_route
import org.meshtastic.core.resources.ic_signal_cellular_alt
import org.meshtastic.core.resources.ic_thermostat
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.node_map
import org.meshtastic.core.resources.pax_metrics_log
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.power_metrics_log
@ -44,7 +42,6 @@ import org.meshtastic.core.resources.traceroute_log
enum class LogsType(val titleRes: StringResource, val icon: DrawableResource, val routeFactory: (Int) -> Route) {
DEVICE(Res.string.device_metrics_log, Res.drawable.ic_charging_station, { NodeDetailRoute.DeviceMetrics(it) }),
NODE_MAP(Res.string.node_map, Res.drawable.ic_map, { NodeDetailRoute.NodeMap(it) }),
POSITIONS(Res.string.position_log, Res.drawable.ic_location_on, { NodeDetailRoute.PositionLog(it) }),
ENVIRONMENT(Res.string.env_metrics_log, Res.drawable.ic_thermostat, { NodeDetailRoute.EnvironmentMetrics(it) }),
SIGNAL(Res.string.signal_quality, Res.drawable.ic_signal_cellular_alt, { NodeDetailRoute.SignalMetrics(it) }),

View file

@ -122,11 +122,6 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
)
}
entry<NodeDetailRoute.NodeMap>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current
mapScreen(args.destNum) { backStack.removeLastOrNull() }
}
entry<NodeDetailRoute.TracerouteLog>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(args.destNum) }
metricsViewModel.setNodeId(args.destNum)

View file

@ -0,0 +1,185 @@
/*
* Copyright (c) 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.metrics
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.MeshLog
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import org.meshtastic.proto.Paxcount as ProtoPaxcount
/**
* Tests for `MetricsViewModel.decodePaxFromLog()`.
*
* Uses a minimal testable subclass to access the protected function without wiring the full ViewModel dependency graph.
*/
class DecodePaxFromLogTest {
/**
* Minimal subclass that exposes `decodePaxFromLog` without requiring all ViewModel dependencies. `MetricsViewModel`
* is open, so we override with no-op constructor arguments are not needed we only call the self-contained
* `decodePaxFromLog` method.
*/
private val decoder =
object {
/** Delegates to MetricsViewModel logic extracted into a standalone helper for testing. */
fun decode(log: MeshLog): ProtoPaxcount? = decodePaxFromLogStandalone(log)
}
// ---- Binary proto path ----
@Test
fun binaryProto_validPaxcount_decoded() {
val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 3600)
val payload = ProtoPaxcount.ADAPTER.encode(pax)
val log = meshLogWithPacket(payload, wantResponse = false)
val result = decoder.decode(log)
assertNotNull(result)
assertEquals(10, result.wifi)
assertEquals(5, result.ble)
assertEquals(3600, result.uptime)
}
@Test
fun binaryProto_wantResponse_returnsNull() {
val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 100)
val payload = ProtoPaxcount.ADAPTER.encode(pax)
val log = meshLogWithPacket(payload, wantResponse = true)
assertNull(decoder.decode(log))
}
@Test
fun binaryProto_allZeroValues_returnsNull() {
val pax = ProtoPaxcount(wifi = 0, ble = 0, uptime = 0)
val payload = ProtoPaxcount.ADAPTER.encode(pax)
val log = meshLogWithPacket(payload, wantResponse = false)
assertNull(decoder.decode(log))
}
@Test
fun binaryProto_wrongPortNum_returnsNull() {
val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 100)
val payload = ProtoPaxcount.ADAPTER.encode(pax)
val log = meshLogWithPacket(payload, wantResponse = false, portNum = PortNum.POSITION_APP)
assertNull(decoder.decode(log))
}
// ---- Base64 fallback path ----
@Test
fun base64Fallback_validPayload_decoded() {
val pax = ProtoPaxcount(wifi = 7, ble = 3, uptime = 500)
val bytes = ProtoPaxcount.ADAPTER.encode(pax)
val base64 = okio.ByteString.of(*bytes).base64()
val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = base64)
val result = decoder.decode(log)
assertNotNull(result)
assertEquals(7, result.wifi)
assertEquals(3, result.ble)
}
// ---- Hex fallback path ----
// Note: The hex path (`else if`) in the original code is unreachable for pure hex strings
// because hex chars [0-9a-fA-F] are a strict subset of base64 chars [A-Za-z0-9+/=].
// The base64 `if` branch always matches first. The hex fallback would only trigger for
// strings that fail the base64 regex but pass the hex regex — which is impossible given
// the charsets. This is documented here as a known design characteristic of decodePaxFromLog().
// ---- Error handling ----
@Test
fun invalidRawMessage_returnsNull() {
val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = "not-valid-anything!@#")
assertNull(decoder.decode(log))
}
@Test
fun emptyLog_returnsNull() {
val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = "")
assertNull(decoder.decode(log))
}
// ---- Helpers ----
private fun meshLogWithPacket(
payload: ByteArray,
wantResponse: Boolean,
portNum: PortNum = PortNum.PAXCOUNTER_APP,
): MeshLog {
val data = Data(portnum = portNum, payload = payload.toByteString(), want_response = wantResponse)
val packet = MeshPacket(decoded = data)
val fromRadio = FromRadio(packet = packet)
return MeshLog(
uuid = "test",
message_type = "packet",
received_date = nowSeconds * 1000,
raw_message = "",
fromRadio = fromRadio,
)
}
}
/**
* Standalone reimplementation of `MetricsViewModel.decodePaxFromLog()` for testing.
*
* This avoids needing to instantiate the full ViewModel with all its dependencies. The logic is identical to the
* ViewModel method.
*/
@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
private fun decodePaxFromLogStandalone(log: MeshLog): ProtoPaxcount? {
try {
val packet = log.fromRadio.packet
val decoded = packet?.decoded
if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) {
if (decoded.want_response == true) return null
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) return pax
}
} catch (e: Exception) {
// Swallow, fall through to alternative parsing
}
try {
val base64 = log.raw_message.trim()
if (base64.matches(Regex("^[A-Za-z0-9+/=\\r\\n]+$"))) {
val bytes = base64.okioDecodeBase64()
return ProtoPaxcount.ADAPTER.decode(bytes)
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {
val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return ProtoPaxcount.ADAPTER.decode(bytes)
}
} catch (e: Exception) {
// Swallow
}
return null
}
private fun String.okioDecodeBase64(): ByteArray = this.decodeBase64()?.toByteArray() ?: ByteArray(0)

View file

@ -0,0 +1,275 @@
/*
* Copyright (c) 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.metrics
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Telemetry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@Suppress("MagicNumber")
class EnvironmentMetricsForGraphingTest {
private val now = nowSeconds.toInt()
private fun telemetry(time: Int = now, env: EnvironmentMetrics) = Telemetry(time = time, environment_metrics = env)
// ---- Empty input ----
@Test
fun emptyMetrics_returnsDefaultGraphingData() {
val state = EnvironmentMetricsState(emptyList())
val result = state.environmentMetricsForGraphing()
assertTrue(result.metrics.isEmpty())
assertTrue(result.shouldPlot.none { it })
}
// ---- Fahrenheit conversion ----
@Test
fun useFahrenheit_convertsTemperatureMinMax() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(temperature = 0f)),
telemetry(env = EnvironmentMetrics(temperature = 100f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true)
assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
// 0C = 32F, 100C = 212F
assertEquals(32f, result.rightMinMax.first, 0.01f)
assertEquals(212f, result.rightMinMax.second, 0.01f)
}
@Test
fun useFahrenheit_convertsSoilTemperature() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(soil_temperature = 20f)),
telemetry(env = EnvironmentMetrics(soil_temperature = 30f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true)
assertTrue(result.shouldPlot[Environment.SOIL_TEMPERATURE.ordinal])
// 20C = 68F, 30C = 86F
assertEquals(68f, result.rightMinMax.first, 0.01f)
assertEquals(86f, result.rightMinMax.second, 0.01f)
}
// ---- Humidity filtering ----
@Test
fun humidity_zeroFilteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(relative_humidity = 0.0f)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.HUMIDITY.ordinal])
}
@Test
fun humidity_nonZeroIncluded() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(relative_humidity = 45f)),
telemetry(env = EnvironmentMetrics(relative_humidity = 65f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal])
assertEquals(45f, result.rightMinMax.first, 0.01f)
assertEquals(65f, result.rightMinMax.second, 0.01f)
}
// ---- IAQ sentinel filtering ----
@Test
fun iaq_intMinValueFilteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(iaq = Int.MIN_VALUE)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.IAQ.ordinal])
}
@Test
fun iaq_validValueIncluded() {
val metrics =
listOf(telemetry(env = EnvironmentMetrics(iaq = 50)), telemetry(env = EnvironmentMetrics(iaq = 150)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.IAQ.ordinal])
assertEquals(50f, result.rightMinMax.first, 0.01f)
assertEquals(150f, result.rightMinMax.second, 0.01f)
}
// ---- Soil moisture sentinel filtering ----
@Test
fun soilMoisture_intMinValueFilteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(soil_moisture = Int.MIN_VALUE)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.SOIL_MOISTURE.ordinal])
}
@Test
fun soilMoisture_validValueIncluded() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(soil_moisture = 30)),
telemetry(env = EnvironmentMetrics(soil_moisture = 70)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.SOIL_MOISTURE.ordinal])
}
// ---- Barometric pressure (left axis) ----
@Test
fun barometricPressure_onLeftAxis() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(barometric_pressure = 1013.25f)),
telemetry(env = EnvironmentMetrics(barometric_pressure = 1020.50f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal])
assertEquals(1013.25f, result.leftMinMax.first, 0.01f)
assertEquals(1020.50f, result.leftMinMax.second, 0.01f)
}
@Test
fun barometricPressure_doesNotAffectRightAxis() {
// Only pressure, no other metrics
val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = 1013.25f)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
// rightMinMax should be 0/1 defaults since no right-axis metrics
assertEquals(0f, result.rightMinMax.first, 0.01f)
assertEquals(1f, result.rightMinMax.second, 0.01f)
}
// ---- Lux, UV lux, wind speed, radiation ----
@Test
fun lux_plotted() {
val metrics =
listOf(telemetry(env = EnvironmentMetrics(lux = 500f)), telemetry(env = EnvironmentMetrics(lux = 1200f)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.LUX.ordinal])
assertEquals(500f, result.rightMinMax.first, 0.01f)
assertEquals(1200f, result.rightMinMax.second, 0.01f)
}
@Test
fun uvLux_plotted() {
val metrics =
listOf(telemetry(env = EnvironmentMetrics(uv_lux = 2f)), telemetry(env = EnvironmentMetrics(uv_lux = 8f)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.UV_LUX.ordinal])
}
@Test
fun windSpeed_plotted() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(wind_speed = 5f)),
telemetry(env = EnvironmentMetrics(wind_speed = 25f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.WIND_SPEED.ordinal])
}
@Test
fun radiation_positiveValuesOnly() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(radiation = 0f)),
telemetry(env = EnvironmentMetrics(radiation = 0.15f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.RADIATION.ordinal])
// 0f is filtered out (radiation > 0f only), so min should be 0.15
assertEquals(0.15f, result.rightMinMax.first, 0.01f)
assertEquals(0.15f, result.rightMinMax.second, 0.01f)
}
// ---- NaN filtering ----
@Test
fun nanTemperature_filteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(temperature = Float.NaN)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.TEMPERATURE.ordinal])
}
@Test
fun nanPressure_filteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = Float.NaN)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal])
assertEquals(0f, result.leftMinMax.first, 0.01f)
assertEquals(0f, result.leftMinMax.second, 0.01f)
}
// ---- Multiple metrics combined ----
@Test
fun multipleMetrics_rightAxisMinMaxSpansAll() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(temperature = 10f, relative_humidity = 80f)),
telemetry(env = EnvironmentMetrics(temperature = 30f, relative_humidity = 40f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal])
// right min/max should span both: min(10, 40) = 10, max(30, 80) = 80
assertEquals(10f, result.rightMinMax.first, 0.01f)
assertEquals(80f, result.rightMinMax.second, 0.01f)
}
// ---- Gas resistance ----
// ---- Gas resistance (not currently graphed by environmentMetricsForGraphing) ----
@Test
fun gasResistance_notPlottedByGraphingFunction() {
// Note: GAS_RESISTANCE is defined in the Environment enum but environmentMetricsForGraphing()
// does not have explicit handling for it. This test documents that current behavior.
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(gas_resistance = 100f)),
telemetry(env = EnvironmentMetrics(gas_resistance = 500f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.GAS_RESISTANCE.ordinal])
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 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.metrics
import org.meshtastic.proto.HardwareModel
import kotlin.test.Test
import kotlin.test.assertEquals
class HardwareModelSafeNumberTest {
@Test
fun knownModel_returnsValue() {
assertEquals(HardwareModel.TBEAM.value, HardwareModel.TBEAM.safeNumber())
}
@Test
fun unset_returnsZero() {
assertEquals(0, HardwareModel.UNSET.safeNumber())
}
@Test
fun customFallback_used() {
// Known model with custom fallback — should still return real value
assertEquals(HardwareModel.HELTEC_V3.value, HardwareModel.HELTEC_V3.safeNumber(fallbackValue = 999))
}
@Test
fun defaultFallback_isNegativeOne() {
// For known models the fallback is never used, but verify the API default
val result = HardwareModel.UNSET.safeNumber()
assertEquals(0, result) // UNSET.value is 0, not the fallback
}
}

View file

@ -0,0 +1,120 @@
/*
* Copyright (c) 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.model
import org.meshtastic.core.common.util.nowSeconds
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@Suppress("MagicNumber")
class TimeFrameTest {
// ---- timeThreshold ----
@Test
fun allTime_thresholdIsZero() {
assertEquals(0L, TimeFrame.ALL_TIME.timeThreshold(now = 1000000L))
}
@Test
fun oneHour_thresholdIsNowMinus3600() {
val now = 1000000L
assertEquals(now - 3600, TimeFrame.ONE_HOUR.timeThreshold(now = now))
}
@Test
fun twentyFourHours_thresholdIsNowMinus86400() {
val now = 1000000L
assertEquals(now - 86400, TimeFrame.TWENTY_FOUR_HOURS.timeThreshold(now = now))
}
@Test
fun sevenDays_thresholdIsNowMinus604800() {
val now = 1000000L
assertEquals(now - 604800, TimeFrame.SEVEN_DAYS.timeThreshold(now = now))
}
@Test
fun twoWeeks_thresholdIsCorrect() {
val now = 2000000L
assertEquals(now - 1209600, TimeFrame.TWO_WEEKS.timeThreshold(now = now))
}
@Test
fun oneMonth_thresholdIsCorrect() {
val now = 3000000L
assertEquals(now - 2592000, TimeFrame.ONE_MONTH.timeThreshold(now = now))
}
// ---- isAvailable ----
@Test
fun allTime_alwaysAvailable() {
assertTrue(TimeFrame.ALL_TIME.isAvailable(oldestTimestampSeconds = nowSeconds, now = nowSeconds))
}
@Test
fun oneHour_alwaysAvailable() {
assertTrue(TimeFrame.ONE_HOUR.isAvailable(oldestTimestampSeconds = nowSeconds, now = nowSeconds))
}
@Test
fun twentyFourHours_availableWhenDataOlderThan24h() {
val now = 1000000L
val oldest = now - 90000 // 25 hours ago
assertTrue(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
@Test
fun twentyFourHours_notAvailableWhenDataYoungerThan24h() {
val now = 1000000L
val oldest = now - 3600 // 1 hour ago
assertFalse(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
@Test
fun sevenDays_notAvailableForTwoDayOldData() {
val now = 1000000L
val oldest = now - (2 * 86400) // 2 days ago
assertFalse(TimeFrame.SEVEN_DAYS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
@Test
fun sevenDays_availableForEightDayOldData() {
val now = 1000000L
val oldest = now - (8 * 86400) // 8 days ago
assertTrue(TimeFrame.SEVEN_DAYS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
@Test
fun isAvailable_exactBoundary_returnsTrue() {
val now = 1000000L
// Exactly 24 hours of data range
val oldest = now - 86400
assertTrue(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
@Test
fun isAvailable_justUnderBoundary_returnsFalse() {
val now = 1000000L
// One second less than 24 hours
val oldest = now - 86399
assertFalse(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
}