feat(metrics): redesign position log with SelectableMetricCard and add CSV export to all metrics screens (#5062)

This commit is contained in:
James Rich 2026-04-10 20:26:26 -05:00 committed by GitHub
parent 37e9e2c8f0
commit a6423d0a0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 398 additions and 251 deletions

View file

@ -124,20 +124,21 @@ fun MapView.addPolyline(density: Density, geoPoints: List<GeoPoint>, onClick: ()
return polyline
}
fun MapView.addPositionMarkers(positions: List<Position>, onClick: () -> Unit): List<Marker> {
fun MapView.addPositionMarkers(positions: List<Position>, onClick: (Int) -> Unit): List<Marker> {
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation)
val markers = positions.map {
Marker(this).apply {
icon = navIcon
rotation = ((it.ground_track ?: 0) * 1e-5).toFloat()
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
position = GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7)
setOnMarkerClickListener { _, _ ->
onClick()
true
val markers =
positions.map { pos ->
Marker(this).apply {
icon = navIcon
rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat()
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7)
setOnMarkerClickListener { _, _ ->
onClick(pos.time)
true
}
}
}
}
overlays.addAll(markers)
return markers

View file

@ -26,9 +26,17 @@ 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]).
*
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
*/
@Composable
fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
fun NodeTrackMap(
destNum: Int,
positions: List<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
NodeTrackOsmMap(
@ -36,5 +44,7 @@ fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = M
applicationId = vm.applicationId,
mapStyleId = vm.mapStyleId,
modifier = modifier,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
)
}

View file

@ -64,6 +64,8 @@ import kotlin.math.roundToInt
* 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.
*
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
*
* 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.
*/
@ -73,6 +75,8 @@ fun NodeTrackOsmMap(
applicationId: String,
mapStyleId: Int,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
mapViewModel: MapViewModel = koinViewModel(),
) {
val density = LocalDensity.current
@ -109,7 +113,15 @@ fun NodeTrackOsmMap(
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(filteredPositions) {}
map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) }
// Center on selected position
if (selectedPositionTime != null) {
val selected = filteredPositions.find { it.time == selectedPositionTime }
if (selected != null) {
val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D)
map.controller.animateTo(point)
}
}
},
)

View file

@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
@ -155,7 +156,12 @@ sealed interface GoogleMapMode {
data object Main : GoogleMapMode
/** Focused node position track: polyline + gradient markers for historical positions. */
data class NodeTrack(val focusedNode: Node?, val positions: List<Position>) : GoogleMapMode
data class NodeTrack(
val focusedNode: Node?,
val positions: List<Position>,
val selectedPositionTime: Int? = null,
val onPositionSelected: ((Int) -> Unit)? = null,
) : GoogleMapMode
/** Traceroute visualization: offset forward/return polylines + hop markers. */
data class Traceroute(
@ -424,6 +430,17 @@ fun MapView(
Logger.d { "Error centering track map: ${e.message}" }
}
}
// Animate to selected position marker when card is tapped in the list
LaunchedEffect(mode.selectedPositionTime) {
val selectedTime = mode.selectedPositionTime ?: return@LaunchedEffect
val selectedPos = sortedTrackPositions.find { it.time == selectedTime } ?: return@LaunchedEffect
try {
cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng()))
} catch (e: IllegalStateException) {
Logger.d { "Error animating to selected position: ${e.message}" }
}
}
}
if (mode is GoogleMapMode.Traceroute) {
@ -577,6 +594,8 @@ fun MapView(
sortedPositions = sortedTrackPositions,
displayUnits = displayUnits,
myNodeNum = myNodeNum,
selectedPositionTime = mode.selectedPositionTime,
onPositionSelected = mode.onPositionSelected,
)
}
}
@ -808,17 +827,24 @@ private fun MainMapContent(
* Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from
* transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a
* [TripOrigin] dot with an info-window on tap.
*
* When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and
* elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization.
*/
@OptIn(MapsComposeExperimentalApi::class)
@Composable
@Suppress("LongMethod")
private fun NodeTrackOverlay(
focusedNode: Node,
sortedPositions: List<Position>,
displayUnits: DisplayUnits,
myNodeNum: Int?,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
val activeNodeZIndex = if (isHighPriority) 5f else 4f
val selectedColor = MaterialTheme.colorScheme.primary
sortedPositions.forEachIndexed { index, position ->
key(position.time) {
@ -829,13 +855,23 @@ private fun NodeTrackOverlay(
} else {
1f
}
val color = Color(focusedNode.colors.second).copy(alpha = alpha)
val isSelected = position.time == selectedPositionTime
val color =
if (isSelected) {
selectedColor
} else {
Color(focusedNode.colors.second).copy(alpha = alpha)
}
if (index == sortedPositions.lastIndex) {
MarkerComposable(
state = markerState,
zIndex = activeNodeZIndex,
alpha = if (isHighPriority) 1.0f else 0.9f,
onClick = {
onPositionSelected?.invoke(position.time)
false // Allow default info window behavior
},
) {
NodeChip(node = focusedNode)
}
@ -844,13 +880,18 @@ private fun NodeTrackOverlay(
state = markerState,
title = stringResource(Res.string.position),
snippet = formatAgo(position.time),
zIndex = 1f + alpha,
zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha,
onClick = {
onPositionSelected?.invoke(position.time)
false // Allow default info window behavior
},
infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) },
) {
Icon(
imageVector = MeshtasticIcons.TripOrigin,
contentDescription = stringResource(Res.string.track_point),
tint = color,
modifier = if (isSelected) Modifier.size(32.dp) else Modifier,
)
}
}

View file

@ -31,11 +31,28 @@ import org.meshtastic.proto.Position
* [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).
*
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
*/
@Composable
fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
fun NodeTrackMap(
destNum: Int,
positions: List<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
val focusedNode by vm.node.collectAsStateWithLifecycle()
MapView(modifier = modifier, mode = GoogleMapMode.NodeTrack(focusedNode = focusedNode, positions = positions))
MapView(
modifier = modifier,
mode =
GoogleMapMode.NodeTrack(
focusedNode = focusedNode,
positions = positions,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
),
)
}

View file

@ -175,8 +175,14 @@ class MainActivity : ComponentActivity() {
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier ->
org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier)
{ destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
org.meshtastic.app.map.node.NodeTrackMap(
destNum,
positions,
modifier,
selectedPositionTime,
onPositionSelected,
)
},
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
LocalTracerouteMapProvider provides