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

View file

@ -27,10 +27,24 @@ import org.meshtastic.proto.Position
* 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).
*
* Supports optional synchronized selection:
* - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When
* non-null, the map should visually highlight the corresponding marker and center the camera on it.
* - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so
* the host can synchronize the card list.
*
* 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") }
compositionLocalOf<
@Composable (
destNum: Int,
positions: List<Position>,
modifier: Modifier,
selectedPositionTime: Int?,
onPositionSelected: ((Int) -> Unit)?,
) -> Unit,
> {
{ _, _, _, _, _ -> PlaceholderScreen("Position Track Map") }
}

View file

@ -37,6 +37,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
@ -68,12 +69,14 @@ 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.save
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.BarChart
import org.meshtastic.core.ui.icon.Info
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
/**
* A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point
@ -217,8 +220,10 @@ fun AdaptiveMetricLayout(
* A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and chart-to-list
* synchronisation.
*
* @param extraActions Additional composable actions rendered in the app bar before the expand/collapse toggle (e.g. a
* @param extraActions Additional composable actions rendered in the app bar before the standard buttons (e.g. a
* cooldown traceroute button).
* @param onExportCsv When non-null, a Save [IconButton] is rendered in the app bar that invokes this callback. This
* centralises the CSV export affordance so individual screens only need to provide the export logic.
*/
@Composable
@Suppress("LongMethod")
@ -231,13 +236,14 @@ fun <T> BaseMetricScreen(
timeProvider: (T) -> Double,
infoData: List<InfoDialogData> = emptyList(),
onRequestTelemetry: (() -> Unit)? = null,
onExportCsv: (() -> Unit)? = null,
extraActions: @Composable () -> Unit = {},
chartPart: @Composable (Modifier, Double?, VicoScrollState, (Double) -> Unit) -> Unit,
listPart: @Composable (Modifier, Double?, LazyListState, (Double) -> Unit) -> Unit,
controlPart: @Composable () -> Unit = {},
) {
var displayInfoDialog by remember { mutableStateOf(false) }
var isChartExpanded by remember { mutableStateOf(false) }
var displayInfoDialog by rememberSaveable { mutableStateOf(false) }
var isChartExpanded by rememberSaveable { mutableStateOf(false) }
val lazyListState = rememberLazyListState()
val vicoScrollState =
@ -259,6 +265,14 @@ fun <T> BaseMetricScreen(
onNavigateUp = onNavigateUp,
actions = {
extraActions()
if (onExportCsv != null && data.isNotEmpty()) {
IconButton(onClick = onExportCsv) {
Icon(
imageVector = MeshtasticIcons.Save,
contentDescription = stringResource(Res.string.save),
)
}
}
IconButton(onClick = { isChartExpanded = !isChartExpanded }) {
Icon(
imageVector =

View file

@ -81,6 +81,7 @@ 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.core.ui.util.rememberSaveFileLauncher
import org.meshtastic.proto.Telemetry
private enum class Device(val color: Color) {
@ -116,6 +117,8 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.deviceMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() }
val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDeviceMetricsCSV(uri, data) }
val hasBattery = remember(data) { data.any { it.device_metrics?.battery_level != null } }
val hasVoltage = remember(data) { data.any { it.device_metrics?.voltage != null } }
val hasChUtil = remember(data) { data.any { it.device_metrics?.channel_utilization != null } }
@ -167,6 +170,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
timeProvider = { it.time.toDouble() },
infoData = infoItems,
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.DEVICE) },
onExportCsv = { exportLauncher("device_metrics.csv", "text/csv") },
controlPart = {
TimeFrameSelector(
selectedTimeFrame = timeFrame,

View file

@ -71,6 +71,7 @@ import org.meshtastic.core.resources.wind_speed
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.rememberSaveFileLauncher
import org.meshtastic.proto.Telemetry
@Composable
@ -81,6 +82,10 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val exportLauncher = rememberSaveFileLauncher { uri ->
viewModel.saveEnvironmentMetricsCSV(uri, filteredTelemetries)
}
BaseMetricScreen(
onNavigateUp = onNavigateUp,
telemetryType = TelemetryType.ENVIRONMENT,
@ -90,6 +95,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
timeProvider = { it.time.toDouble() },
infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)),
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) },
onExportCsv = { exportLauncher("environment_metrics.csv", "text/csv") },
controlPart = {
TimeFrameSelector(
selectedTimeFrame = timeFrame,

View file

@ -67,6 +67,7 @@ import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import kotlin.time.Instant
@ -320,35 +321,111 @@ open class MetricsViewModel(
Logger.d { "MetricsViewModel cleared" }
}
fun savePositionCSV(uri: MeshtasticUri) {
viewModelScope.launch(dispatchers.main) {
val positions = state.value.positionLogs
// region --- CSV Export ---
/**
* Shared CSV export helper. Writes [header] then iterates [rows], converting each item to a CSV line via
* [rowMapper]. The mapper returns only the data columns; date and time columns are prepended automatically from the
* epoch-seconds timestamp extracted by [epochSeconds].
*/
private fun <T> exportCsv(
uri: MeshtasticUri,
header: String,
rows: List<T>,
epochSeconds: (T) -> Long,
rowMapper: (T) -> String,
) {
viewModelScope.launch(dispatchers.io) {
fileService.write(uri) { sink ->
sink.writeUtf8(
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n",
)
positions.forEach { position ->
val localDateTime =
Instant.fromEpochSeconds(position.time.toLong())
.toLocalDateTime(TimeZone.currentSystemDefault())
val rxDateTime = "\"${localDateTime.date}\",\"${localDateTime.time}\""
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) * GeoConstants.HEADING_DEG)
sink.writeUtf8(
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n",
)
sink.writeUtf8(header)
rows.forEach { item ->
val dt =
Instant.fromEpochSeconds(epochSeconds(item)).toLocalDateTime(TimeZone.currentSystemDefault())
sink.writeUtf8("\"${dt.date}\",\"${dt.time}\",${rowMapper(item)}\n")
}
}
}
}
fun savePositionCSV(uri: MeshtasticUri, data: List<org.meshtastic.proto.Position>) {
exportCsv(
uri = uri,
header =
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\"," + "\"satsInView\",\"speed\",\"heading\"\n",
rows = data,
epochSeconds = { it.time.toLong() },
) { pos ->
val lat = (pos.latitude_i ?: 0) * GeoConstants.DEG_D
val lon = (pos.longitude_i ?: 0) * GeoConstants.DEG_D
val heading = formatString("%.2f", (pos.ground_track ?: 0) * GeoConstants.HEADING_DEG)
"\"$lat\",\"$lon\",\"${pos.altitude}\",\"${pos.sats_in_view}\",\"${pos.ground_speed}\",\"$heading\""
}
}
fun saveDeviceMetricsCSV(uri: MeshtasticUri, data: List<Telemetry>) {
exportCsv(
uri = uri,
header =
"\"date\",\"time\",\"batteryLevel\",\"voltage\",\"channelUtilization\"," +
"\"airUtilTx\",\"uptimeSeconds\"\n",
rows = data,
epochSeconds = { it.time.toLong() },
) { t ->
val dm = t.device_metrics
"\"${dm?.battery_level ?: ""}\",\"${dm?.voltage ?: ""}\"," +
"\"${dm?.channel_utilization ?: ""}\",\"${dm?.air_util_tx ?: ""}\"," +
"\"${dm?.uptime_seconds ?: ""}\""
}
}
fun saveEnvironmentMetricsCSV(uri: MeshtasticUri, data: List<Telemetry>) {
exportCsv(
uri = uri,
header =
"\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\"," +
"\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\"," +
"\"soilMoisture\"\n",
rows = data,
epochSeconds = { it.time.toLong() },
) { t ->
val em = t.environment_metrics
"\"${em?.temperature ?: ""}\",\"${em?.relative_humidity ?: ""}\"," +
"\"${em?.barometric_pressure ?: ""}\",\"${em?.gas_resistance ?: ""}\"," +
"\"${em?.iaq ?: ""}\",\"${em?.wind_speed ?: ""}\"," +
"\"${em?.wind_direction ?: ""}\",\"${em?.soil_temperature ?: ""}\"," +
"\"${em?.soil_moisture ?: ""}\""
}
}
fun saveSignalMetricsCSV(uri: MeshtasticUri, data: List<MeshPacket>) {
exportCsv(
uri = uri,
header = "\"date\",\"time\",\"rssi\",\"snr\"\n",
rows = data,
epochSeconds = { it.rx_time.toLong() },
) { p ->
"\"${p.rx_rssi}\",\"${p.rx_snr}\""
}
}
fun savePowerMetricsCSV(uri: MeshtasticUri, data: List<Telemetry>) {
exportCsv(
uri = uri,
header =
"\"date\",\"time\",\"ch1Voltage\",\"ch1Current\",\"ch2Voltage\",\"ch2Current\"," +
"\"ch3Voltage\",\"ch3Current\"\n",
rows = data,
epochSeconds = { it.time.toLong() },
) { t ->
val pm = t.power_metrics
"\"${pm?.ch1_voltage ?: ""}\",\"${pm?.ch1_current ?: ""}\"," +
"\"${pm?.ch2_voltage ?: ""}\",\"${pm?.ch2_current ?: ""}\"," +
"\"${pm?.ch3_voltage ?: ""}\",\"${pm?.ch3_current ?: ""}\""
}
}
// endregion
@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? {
try {

View file

@ -14,27 +14,32 @@
* 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.layout.Arrangement
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.util.GeoConstants.DEG_D
import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
@ -43,69 +48,95 @@ import org.meshtastic.core.resources.heading
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.speed_kmh
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.core.ui.theme.GraphColors
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
/**
* A [SelectableMetricCard]-based position item that matches the visual style of [DeviceMetricsCard],
* [SignalMetricsCard], and other metric cards. Replaces the previous table-row layout with a card that shows timestamp,
* coordinates, satellites, altitude, speed, and heading.
*/
@Composable
private fun RowScope.PositionText(text: String, weight: Float) {
Text(
text = text,
modifier = Modifier.weight(weight),
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
private const val WEIGHT_10 = .10f
private const val WEIGHT_15 = .15f
private const val WEIGHT_20 = .20f
private const val WEIGHT_40 = .40f
@Composable
fun PositionLogHeader(compactWidth: Boolean) {
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
PositionText(stringResource(Res.string.latitude), WEIGHT_20)
PositionText(stringResource(Res.string.longitude), WEIGHT_20)
PositionText(stringResource(Res.string.sats), WEIGHT_10)
PositionText(stringResource(Res.string.alt), WEIGHT_15)
if (!compactWidth) {
PositionText(stringResource(Res.string.speed), WEIGHT_15)
PositionText(stringResource(Res.string.heading), WEIGHT_15)
}
PositionText(stringResource(Res.string.timestamp), WEIGHT_40)
}
}
@Composable
fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText(formatString("%.5f", (position.latitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(formatString("%.5f", (position.longitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(position.sats_in_view.toString(), WEIGHT_10)
PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15)
if (!compactWidth) {
PositionText(stringResource(Res.string.speed_kmh, position.ground_speed ?: 0), WEIGHT_15)
PositionText(formatString("%.0f°", (position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
}
PositionText(position.formatPositionTime(), WEIGHT_40)
}
}
@Composable
fun ColumnScope.PositionList(
compactWidth: Boolean,
positions: List<Position>,
@Suppress("LongMethod")
fun PositionCard(
position: Position,
displayUnits: Config.DisplayConfig.DisplayUnits,
isSelected: Boolean,
onClick: () -> Unit,
) {
LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
items(positions) { position -> PositionItem(compactWidth, position, displayUnits) }
val time = position.time.toLong() * MS_PER_SEC
val latitude = formatString("%.5f", (position.latitude_i ?: 0) * DEG_D)
val longitude = formatString("%.5f", (position.longitude_i ?: 0) * DEG_D)
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
/* Timestamp */
Text(
text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
/* Coordinates */
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
MetricValueRow(color = GraphColors.Blue, text = "${stringResource(Res.string.latitude)}: $latitude")
Spacer(Modifier.width(12.dp))
MetricValueRow(
color = GraphColors.Green,
text = "${stringResource(Res.string.longitude)}: $longitude",
)
}
Text(
text = "${stringResource(Res.string.sats)}: ${position.sats_in_view}",
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
Spacer(modifier = Modifier.height(4.dp))
/* Alt, Speed, Heading */
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
MetricValueRow(
color = GraphColors.Purple,
text =
"${stringResource(Res.string.alt)}: ${
(position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits)
}",
)
if (position.ground_speed != null && position.ground_speed != 0) {
Spacer(Modifier.width(12.dp))
MetricValueRow(
color = GraphColors.Gold,
text = stringResource(Res.string.speed_kmh, position.ground_speed ?: 0),
)
}
}
if (position.ground_track != null && position.ground_track != 0) {
Text(
text =
"${stringResource(Res.string.heading)}: ${
formatString("%.0f", (position.ground_track ?: 0) * HEADING_DEG)
}\u00B0",
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
}

View file

@ -16,158 +16,69 @@
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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
import androidx.compose.ui.unit.dp
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
import org.meshtastic.core.ui.util.rememberSaveFileLauncher
@Composable
private fun ActionButtons(
clearButtonEnabled: Boolean,
onClear: () -> Unit,
saveButtonEnabled: Boolean,
onSave: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = onClear,
enabled = clearButtonEnabled,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.clear))
Spacer(Modifier.width(8.dp))
Text(text = stringResource(Res.string.clear))
}
OutlinedButton(modifier = Modifier.weight(1f), onClick = onSave, enabled = saveButtonEnabled) {
Icon(imageVector = MeshtasticIcons.Save, contentDescription = stringResource(Res.string.save))
Spacer(Modifier.width(8.dp))
Text(text = stringResource(Res.string.save))
}
}
}
@Suppress("LongMethod")
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val positions = state.positionLogs
val exportPositionLauncher =
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 exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) }
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)
}
}
},
onClickChip = {},
)
BaseMetricScreen(
onNavigateUp = onNavigateUp,
telemetryType = null,
titleRes = Res.string.position_log,
nodeName = state.node?.user?.long_name ?: "",
data = positions,
timeProvider = { it.time.toDouble() },
onExportCsv = { exportPositionLauncher("position.csv", "text/csv") },
extraActions = {
if (positions.isNotEmpty()) {
IconButton(onClick = { viewModel.clearPosition() }) {
Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.clear))
}
}
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestPosition() }) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
}
}
},
) { innerPadding ->
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") },
)
}
}
},
)
}
}
chartPart = { modifier, selectedX, _, onPointSelected ->
val selectedTime = selectedX?.toInt()
trackMap(destNum, positions, modifier, selectedTime) { time -> onPointSelected(time.toDouble()) }
},
listPart = { modifier, selectedX, lazyListState, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {
itemsIndexed(positions) { _, position ->
PositionCard(
position = position,
displayUnits = state.displayUnits,
isSelected = position.time.toDouble() == selectedX,
onClick = { onCardClick(position.time.toDouble()) },
)
}
}
},
)
}

View file

@ -40,6 +40,7 @@ import androidx.compose.runtime.LaunchedEffect
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
import androidx.compose.ui.graphics.Color
@ -72,6 +73,7 @@ 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.core.ui.util.rememberSaveFileLauncher
import org.meshtastic.proto.Telemetry
private enum class PowerMetric(val color: Color) {
@ -103,13 +105,16 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle()
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.powerMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() }
val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.savePowerMetricsCSV(uri, data) }
val availableChannels =
remember(data) {
PowerChannel.entries.filter { channel ->
data.any { !retrieveVoltage(channel, it).isNaN() || !retrieveCurrent(channel, it).isNaN() }
}
}
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
var selectedChannel by rememberSaveable { mutableStateOf(PowerChannel.ONE) }
BaseMetricScreen(
onNavigateUp = onNavigateUp,
@ -119,6 +124,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
data = data,
timeProvider = { it.time.toDouble() },
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.POWER) },
onExportCsv = { exportLauncher("power_metrics.csv", "text/csv") },
controlPart = {
Column {
TimeFrameSelector(

View file

@ -55,13 +55,12 @@ 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
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.snr
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.core.ui.util.rememberSaveFileLauncher
import org.meshtastic.proto.MeshPacket
private enum class SignalMetric(val color: Color) {
@ -83,6 +82,8 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle()
val data = state.signalMetrics.filter { it.rx_time.toLong() >= timeFrame.timeThreshold() }
val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveSignalMetricsCSV(uri, data) }
BaseMetricScreen(
onNavigateUp = onNavigateUp,
telemetryType = TelemetryType.LOCAL_STATS,
@ -91,11 +92,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
data = data,
timeProvider = { it.rx_time.toDouble() },
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) },
infoData =
listOf(
InfoDialogData(Res.string.snr, Res.string.snr_definition, SignalMetric.SNR.color),
InfoDialogData(Res.string.rssi, Res.string.rssi_definition, SignalMetric.RSSI.color),
),
onExportCsv = { exportLauncher("signal_metrics.csv", "text/csv") },
controlPart = {
TimeFrameSelector(
selectedTimeFrame = timeFrame,

View file

@ -211,7 +211,7 @@ class MetricsViewModelTest {
awaitItem() // with position
val uri = MeshtasticUri("content://test")
vm.savePositionCSV(uri)
vm.savePositionCSV(uri, listOf(testPosition))
runCurrent()
verifySuspend { fileService.write(uri, any()) }