From a6423d0a0f4255c92a1af46f5522fc8d45ed441a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:26:26 -0500 Subject: [PATCH] feat(metrics): redesign position log with SelectableMetricCard and add CSV export to all metrics screens (#5062) --- .../meshtastic/app/map/MapViewExtensions.kt | 23 +-- .../meshtastic/app/map/node/NodeTrackMap.kt | 12 +- .../app/map/node/NodeTrackOsmMap.kt | 14 +- .../kotlin/org/meshtastic/app/map/MapView.kt | 47 ++++- .../meshtastic/app/map/node/NodeTrackMap.kt | 21 ++- .../kotlin/org/meshtastic/app/MainActivity.kt | 10 +- .../core/ui/util/LocalNodeTrackMapProvider.kt | 18 +- .../feature/node/metrics/BaseMetricChart.kt | 20 +- .../feature/node/metrics/DeviceMetrics.kt | 4 + .../node/metrics/EnvironmentMetrics.kt | 6 + .../feature/node/metrics/MetricsViewModel.kt | 123 ++++++++++--- .../node/metrics/PositionLogComponents.kt | 157 +++++++++------- .../node/metrics/PositionLogScreens.kt | 173 +++++------------- .../feature/node/metrics/PowerMetrics.kt | 8 +- .../feature/node/metrics/SignalMetrics.kt | 11 +- .../node/metrics/MetricsViewModelTest.kt | 2 +- 16 files changed, 398 insertions(+), 251 deletions(-) diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt index 04f896d18..3cc0dbaf0 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt @@ -124,20 +124,21 @@ fun MapView.addPolyline(density: Density, geoPoints: List, onClick: () return polyline } -fun MapView.addPositionMarkers(positions: List, onClick: () -> Unit): List { +fun MapView.addPositionMarkers(positions: List, onClick: (Int) -> Unit): List { 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 diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt index 0178a498e..77b595d88 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -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, modifier: Modifier = Modifier) { +fun NodeTrackMap( + destNum: Int, + positions: List, + modifier: Modifier = Modifier, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, +) { val vm = koinViewModel() vm.setDestNum(destNum) NodeTrackOsmMap( @@ -36,5 +44,7 @@ fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = M applicationId = vm.applicationId, mapStyleId = vm.mapStyleId, modifier = modifier, + selectedPositionTime = selectedPositionTime, + onPositionSelected = onPositionSelected, ) } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt index 64d207a6e..b24e57b63 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt @@ -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) + } + } }, ) diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 0418d76b7..125f861cc 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -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) : GoogleMapMode + data class NodeTrack( + val focusedNode: Node?, + val positions: List, + 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, 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, ) } } diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt index 513957c61..2f7244b97 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -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, modifier: Modifier = Modifier) { +fun NodeTrackMap( + destNum: Int, + positions: List, + modifier: Modifier = Modifier, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, +) { val vm = koinViewModel() 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, + ), + ) } diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 342b845dd..03549c0b3 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -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 diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt index 5ac8eca5a..d0901f0f9 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt @@ -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, modifier: Modifier) -> Unit> { - { _, _, _ -> PlaceholderScreen("Position Track Map") } + compositionLocalOf< + @Composable ( + destNum: Int, + positions: List, + modifier: Modifier, + selectedPositionTime: Int?, + onPositionSelected: ((Int) -> Unit)?, + ) -> Unit, + > { + { _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index cb96607d9..b8e6f0aae 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -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 BaseMetricScreen( timeProvider: (T) -> Double, infoData: List = 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 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 = diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index a3fef636f..f3e02818d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -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, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 2b47fd5e1..12c604a46 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -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, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 51ef4ef8c..8c6ca9222 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -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 exportCsv( + uri: MeshtasticUri, + header: String, + rows: List, + 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) { + 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) { + 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) { + 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) { + 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) { + 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 { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt index 62ab7a0d4..e2f95f04b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogComponents.kt @@ -14,27 +14,32 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@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, +@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, + ) + } + } + } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt index cb7d147d2..e414ea26d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -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()) }, + ) + } + } + }, + ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index ebfae8407..e2064fd5f 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -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( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index 376b55289..ca6fd2d61 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -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, diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt index 34e411af0..961a34dd6 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt @@ -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()) }