mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(metrics): redesign position log with SelectableMetricCard and add CSV export to all metrics screens (#5062)
This commit is contained in:
parent
37e9e2c8f0
commit
a6423d0a0f
16 changed files with 398 additions and 251 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue