mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)
This commit is contained in:
parent
56332f4d77
commit
520fa717a9
71 changed files with 3464 additions and 2169 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|---|---:|---:|---|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ fun MapScreen(
|
|||
) { paddingValues ->
|
||||
LocalMapViewProvider.current?.MapView(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
viewModel = viewModel,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
waypointId = waypointId,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue