diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt index a5069fb59..21c2d4fde 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt @@ -23,32 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.Single import org.meshtastic.core.ui.util.MapViewProvider +/** OSMDroid implementation of [MapViewProvider]. */ @Single class FdroidMapViewProvider : MapViewProvider { @Composable - override fun MapView( - modifier: Modifier, - viewModel: Any, - navigateToNodeDetails: (Int) -> Unit, - focusedNodeNum: Int?, - nodeTracks: List?, - tracerouteOverlay: Any?, - tracerouteNodePositions: Map, - onTracerouteMappableCountChanged: (Int, Int) -> Unit, - waypointId: Int?, - ) { + override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { val mapViewModel: MapViewModel = koinViewModel() LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } - @Suppress("UNCHECKED_CAST") org.meshtastic.app.map.MapView( modifier = modifier, mapViewModel = mapViewModel, navigateToNodeDetails = navigateToNodeDetails, - focusedNodeNum = focusedNodeNum, - nodeTracks = nodeTracks as? List, - tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions as? Map ?: emptyMap(), - onTracerouteMappableCountChanged = onTracerouteMappableCountChanged, ) } } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 5a59b5341..54935b422 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -17,10 +17,8 @@ package org.meshtastic.app.map import android.Manifest -import android.graphics.Paint import androidx.appcompat.content.res.AppCompatResources import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -38,9 +36,11 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -49,6 +49,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -56,8 +57,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -79,6 +78,7 @@ import org.meshtastic.app.map.component.CacheLayout import org.meshtastic.app.map.component.DownloadButton import org.meshtastic.app.map.component.EditWaypointDialog import org.meshtastic.app.map.component.MapButton +import org.meshtastic.app.map.component.MapControlsOverlay import org.meshtastic.app.map.model.CustomTileSource import org.meshtastic.app.map.model.MarkerWithLabel import org.meshtastic.core.common.gpsDisabled @@ -96,6 +96,7 @@ import org.meshtastic.core.resources.delete_for_everyone import org.meshtastic.core.resources.delete_for_me import org.meshtastic.core.resources.expires import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.last_heard_filter_label import org.meshtastic.core.resources.location_disabled import org.meshtastic.core.resources.map_cache_info import org.meshtastic.core.resources.map_cache_manager @@ -105,7 +106,6 @@ import org.meshtastic.core.resources.map_clear_tiles import org.meshtastic.core.resources.map_download_complete import org.meshtastic.core.resources.map_download_errors import org.meshtastic.core.resources.map_download_region -import org.meshtastic.core.resources.map_filter import org.meshtastic.core.resources.map_node_popup_details import org.meshtastic.core.resources.map_offline_manager import org.meshtastic.core.resources.map_purge_fail @@ -114,10 +114,8 @@ import org.meshtastic.core.resources.map_style_selection import org.meshtastic.core.resources.map_subDescription import org.meshtastic.core.resources.map_tile_source import org.meshtastic.core.resources.only_favorites -import org.meshtastic.core.resources.position import org.meshtastic.core.resources.show_precision_circle import org.meshtastic.core.resources.show_waypoints -import org.meshtastic.core.resources.toggle_my_position import org.meshtastic.core.resources.waypoint_delete import org.meshtastic.core.resources.you import org.meshtastic.core.ui.component.BasicListItem @@ -126,18 +124,13 @@ import org.meshtastic.core.ui.icon.Check import org.meshtastic.core.ui.icon.Favorite import org.meshtastic.core.ui.icon.Layers import org.meshtastic.core.ui.icon.Lens -import org.meshtastic.core.ui.icon.LocationDisabled import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.MyLocation import org.meshtastic.core.ui.icon.PinDrop -import org.meshtastic.core.ui.icon.Tune -import org.meshtastic.core.ui.theme.TracerouteColors import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.showToast +import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.model.TracerouteOverlay -import org.meshtastic.feature.map.tracerouteNodeSelection -import org.meshtastic.proto.Position +import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.Waypoint import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable import org.osmdroid.config.Configuration @@ -156,38 +149,23 @@ import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Polygon -import org.osmdroid.views.overlay.Polyline import org.osmdroid.views.overlay.infowindow.InfoWindow import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import java.io.File -import kotlin.math.abs -import kotlin.math.asin -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.sin +import kotlin.math.roundToInt private fun MapView.updateMarkers( nodeMarkers: List, waypointMarkers: List, - trackMarkers: List, - trackPolylines: List, nodeClusterer: RadiusMarkerClusterer, ) { - Logger.d { - "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints ${trackMarkers.size} tracks" - } - - val trackOverlayIds = (trackMarkers + trackPolylines).toSet() + Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" } overlays.removeAll { overlay -> - overlay is MarkerWithLabel || - (overlay is Marker && overlay !in nodeClusterer.items && overlay !in trackOverlayIds) || - (overlay is Polyline && overlay !in trackOverlayIds) + overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items) } overlays.addAll(waypointMarkers) - overlays.addAll(trackPolylines) - overlays.addAll(trackMarkers) nodeClusterer.items.clear() nodeClusterer.items.addAll(nodeMarkers) @@ -225,17 +203,12 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) * @param navigateToNodeDetails Callback to navigate to the details screen of a selected node. */ @OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist -@Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod") +@Suppress("CyclomaticComplexMethod", "LongMethod") @Composable fun MapView( modifier: Modifier = Modifier, mapViewModel: MapViewModel = koinViewModel(), navigateToNodeDetails: (Int) -> Unit, - focusedNodeNum: Int? = null, - nodeTracks: List? = null, - tracerouteOverlay: TracerouteOverlay? = null, - tracerouteNodePositions: Map = emptyMap(), - onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> }, ) { var mapFilterExpanded by remember { mutableStateOf(false) } @@ -334,6 +307,16 @@ fun MapView( } } + // Keep screen on while location tracking is active + LaunchedEffect(myLocationOverlay) { + val activity = context as? android.app.Activity ?: return@LaunchedEffect + if (myLocationOverlay != null) { + activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() @@ -349,77 +332,21 @@ fun MapView( } } - val tracerouteSelection = - remember(tracerouteOverlay, tracerouteNodePositions, nodes) { - mapViewModel.tracerouteNodeSelection( - tracerouteOverlay = tracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions, - nodes = nodes, - ) - } - val overlayNodeNums = tracerouteSelection.overlayNodeNums - val nodeLookup = tracerouteSelection.nodeLookup - val nodesForMarkers = tracerouteSelection.nodesForMarkers - val tracerouteForwardPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.forwardRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() - } - val tracerouteReturnPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.returnRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() - } - LaunchedEffect(tracerouteOverlay, nodesForMarkers) { - if (tracerouteOverlay != null) { - onTracerouteMappableCountChanged(nodesForMarkers.size, tracerouteOverlay.relatedNodeNums.size) - } - } - val tracerouteHeadingReferencePoints = - remember(tracerouteForwardPoints, tracerouteReturnPoints) { - when { - tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints - tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints - else -> emptyList() - } - } - val tracerouteForwardOffsetPoints = - remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { - offsetPolyline( - points = tracerouteForwardPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = tracerouteHeadingReferencePoints, - sideMultiplier = 1.0, - ) - } - val tracerouteReturnOffsetPoints = - remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) { - offsetPolyline( - points = tracerouteReturnPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = tracerouteHeadingReferencePoints, - sideMultiplier = -1.0, - ) - } - val traceroutePolylines = remember { mutableStateListOf() } - var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) } - val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } fun MapView.onNodesChanged(nodes: Collection): List { val nodesWithPosition = nodes.filter { it.validPosition != null } val ourNode = mapViewModel.ourNodeInfo.value - val displayUnits = - mapViewModel.config.display?.units ?: org.meshtastic.proto.Config.DisplayConfig.DisplayUnits.METRIC + val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly return nodesWithPosition.mapNotNull { node -> + if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { + return@mapNotNull null + } if ( - mapFilterStateValue.onlyFavorites && - !node.isFavorite && - !overlayNodeNums.contains(node.num) && - !node.equals(ourNode) + mapFilterStateValue.lastHeardFilter.seconds != 0L && + (nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds && + node.num != ourNode?.num ) { return@mapNotNull null } @@ -580,53 +507,6 @@ fun MapView( invalidate() } - fun MapView.updateTracerouteOverlay(forwardPoints: List, returnPoints: List) { - overlays.removeAll(traceroutePolylines) - traceroutePolylines.clear() - - fun buildPolyline(points: List, color: Int, strokeWidth: Float): Polyline = Polyline().apply { - setPoints(points) - outlinePaint.apply { - this.color = color - this.strokeWidth = strokeWidth - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - style = Paint.Style.STROKE - } - } - - forwardPoints - .takeIf { it.size >= 2 } - ?.let { points -> - traceroutePolylines.add( - buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }), - ) - } - returnPoints - .takeIf { it.size >= 2 } - ?.let { points -> - traceroutePolylines.add( - buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }), - ) - } - overlays.addAll(traceroutePolylines) - invalidate() - } - - LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) { - if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect - val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() - if (allPoints.isNotEmpty()) { - if (allPoints.size == 1) { - map.controller.setCenter(allPoints.first()) - map.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM) - } else { - map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), true) - } - hasCenteredTraceroute = true - } - } - fun MapView.generateBoxOverlay() { overlays.removeAll { it is Polygon } val zoomFactor = 1.3 @@ -689,51 +569,6 @@ fun MapView( } } - fun MapView.onTracksChanged(nodeTracks: List?, focusedNodeNum: Int?): Pair, List> { - if (nodeTracks == null || focusedNodeNum == null) return emptyList() to emptyList() - - val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter - val timeFilteredPositions = - nodeTracks.filter { - lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds - } - val sortedPositions = timeFilteredPositions.sortedBy { it.time } - - val focusedNode = nodes.find { it.num == focusedNodeNum } ?: return emptyList() to emptyList() - val color = focusedNode.colors.second - - val trackPolylines = mutableListOf() - if (sortedPositions.size > 1) { - val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) - segments.forEachIndexed { index, segmentPoints -> - val alpha = (index.toFloat() / (segments.size.toFloat() - 1)) - val polyline = - Polyline().apply { - setPoints( - segmentPoints.map { GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) }, - ) - outlinePaint.color = Color(color).copy(alpha = alpha).toArgb() - outlinePaint.strokeWidth = 8f - } - trackPolylines.add(polyline) - } - } - - val trackMarkers = - sortedPositions.mapIndexedNotNull { index, position -> - if (index == sortedPositions.lastIndex) return@mapIndexedNotNull null - - Marker(this).apply { - this.position = GeoPoint((position.latitude_i ?: 0) * 1e-7, (position.longitude_i ?: 0) * 1e-7) - icon = AppCompatResources.getDrawable(context, R.drawable.ic_map_location_dot) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - title = getString(Res.string.position) - snippet = formatAgo(position.time) - } - } - return trackMarkers to trackPolylines - } - Scaffold( modifier = modifier, floatingActionButton = { @@ -750,14 +585,10 @@ fun MapView( }, modifier = Modifier.fillMaxSize(), update = { mapView -> - mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints) - val (trackMarkers, trackPolylines) = mapView.onTracksChanged(nodeTracks, focusedNodeNum) with(mapView) { updateMarkers( - onNodesChanged(nodesForMarkers), + onNodesChanged(nodes), onWaypointChanged(waypoints.values, selectedWaypointId), - trackMarkers, - trackPolylines, nodeClusterer, ) } @@ -776,122 +607,34 @@ fun MapView( modifier = Modifier.align(Alignment.BottomCenter), ) } else { - @Suppress("MagicNumber") - Column( - modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - MapButton( - onClick = { showMapStyleDialog = true }, - icon = MeshtasticIcons.Layers, - contentDescription = Res.string.map_style_selection, - ) - Box(modifier = Modifier) { - MapButton( - onClick = { mapFilterExpanded = true }, - icon = MeshtasticIcons.Tune, - contentDescription = stringResource(Res.string.map_filter), - ) - DropdownMenu( + MapControlsOverlay( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), + onToggleFilterMenu = { mapFilterExpanded = true }, + filterDropdownContent = { + FdroidMainMapFilterDropdown( expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) { - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = MeshtasticIcons.Favorite, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.only_favorites), - modifier = Modifier.weight(1f), - ) - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleOnlyFavorites() }, - ) - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = MeshtasticIcons.PinDrop, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.show_waypoints), - modifier = Modifier.weight(1f), - ) - Checkbox( - checked = mapFilterState.showWaypoints, - onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowWaypointsOnMap() }, - ) - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = MeshtasticIcons.Lens, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.show_precision_circle), - modifier = Modifier.weight(1f), - ) - @Suppress("MagicNumber") - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - ) - } - } - MapButton( - icon = - if (myLocationOverlay == null) { - MeshtasticIcons.MyLocation - } else { - MeshtasticIcons.LocationDisabled - }, - contentDescription = stringResource(Res.string.toggle_my_position), - ) { + mapFilterState = mapFilterState, + mapViewModel = mapViewModel, + ) + }, + mapTypeContent = { + MapButton( + icon = MeshtasticIcons.Layers, + contentDescription = stringResource(Res.string.map_style_selection), + onClick = { showMapStyleDialog = true }, + ) + }, + isLocationTrackingEnabled = myLocationOverlay != null, + onToggleLocationTracking = { if (locationPermissionsState.allPermissionsGranted) { map.toggleMyLocation() } else { triggerLocationToggleAfterPermission = true locationPermissionsState.launchMultiplePermissionRequest() } - } - } + }, + ) } } } @@ -970,6 +713,103 @@ fun MapView( } } +/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */ +@Composable +private fun FdroidMainMapFilterDropdown( + expanded: Boolean, + onDismissRequest: () -> Unit, + mapFilterState: MapFilterState, + mapViewModel: MapViewModel, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) { + DropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = MeshtasticIcons.Favorite, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f)) + Checkbox( + checked = mapFilterState.onlyFavorites, + onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + onClick = { mapViewModel.toggleOnlyFavorites() }, + ) + DropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = MeshtasticIcons.PinDrop, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f)) + Checkbox( + checked = mapFilterState.showWaypoints, + onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + onClick = { mapViewModel.toggleShowWaypointsOnMap() }, + ) + DropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = MeshtasticIcons.Lens, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f)) + Checkbox( + checked = mapFilterState.showPrecisionCircle, + onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + ) + HorizontalDivider() + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + val filterOptions = LastHeardFilter.entries + val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter) + var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } + Text( + text = + stringResource( + Res.string.last_heard_filter_label, + stringResource(mapFilterState.lastHeardFilter.label), + ), + style = MaterialTheme.typography.labelLarge, + ) + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { + val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) + mapViewModel.setLastHeardFilter(filterOptions[newIndex]) + }, + valueRange = 0f..(filterOptions.size - 1).toFloat(), + steps = filterOptions.size - 2, + ) + } + } +} + @Composable private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) { val selected = remember { mutableStateOf(selectedMapStyle) } @@ -1125,57 +965,4 @@ private fun MapsDialog( } } -private const val EARTH_RADIUS_METERS = 6_371_000.0 -private const val TRACEROUTE_OFFSET_METERS = 100.0 -private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0 -private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5 private const val WAYPOINT_ZOOM = 15.0 - -@Suppress("MagicNumber") -private fun Double.toRad(): Double = this * Math.PI / 180.0 - -private fun bearingRad(from: GeoPoint, to: GeoPoint): Double { - val lat1 = from.latitude.toRad() - val lat2 = to.latitude.toRad() - val dLon = (to.longitude - from.longitude).toRad() - return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)) -} - -private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint { - val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS - val lat1 = latitude.toRad() - val lon1 = longitude.toRad() - val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad)) - val lon2 = - lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2)) - return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2)) -} - -private fun offsetPolyline( - points: List, - offsetMeters: Double, - headingReferencePoints: List = points, - sideMultiplier: Double = 1.0, -): List { - val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points - if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points - - val headings = - headingPoints.mapIndexed { index, _ -> - when (index) { - 0 -> bearingRad(headingPoints[0], headingPoints[1]) - headingPoints.lastIndex -> - bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex]) - - else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1]) - } - } - - return points.mapIndexed { index, point -> - val heading = headings[index.coerceIn(0, headings.lastIndex)] - - @Suppress("MagicNumber") - val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier) - point.offsetPoint(perpendicularHeading, abs(offsetMeters)) - } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt index d6e84d19b..c16d87163 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.app.map -import android.annotation.SuppressLint -import android.content.Context -import android.os.PowerManager import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -32,7 +29,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import co.touchlab.kermit.Logger import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.ITileSource import org.osmdroid.tileprovider.tilesource.TileSourceFactory @@ -41,29 +37,6 @@ import org.osmdroid.util.GeoPoint import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView -@SuppressLint("WakelockTimeout") -private fun PowerManager.WakeLock.safeAcquire() { - if (!isHeld) { - try { - acquire() - } catch (e: SecurityException) { - Logger.e { "WakeLock permission exception: ${e.message}" } - } catch (e: IllegalStateException) { - Logger.e { "WakeLock acquire() exception: ${e.message}" } - } - } -} - -private fun PowerManager.WakeLock.safeRelease() { - if (isHeld) { - try { - release() - } catch (e: IllegalStateException) { - Logger.e { "WakeLock release() exception: ${e.message}" } - } - } -} - private const val MIN_ZOOM_LEVEL = 1.5 private const val MAX_ZOOM_LEVEL = 20.0 private const val DEFAULT_ZOOM_LEVEL = 15.0 @@ -136,22 +109,13 @@ internal fun rememberMapViewWithLifecycle( } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { - val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager - - @Suppress("DEPRECATION") - val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock") - - wakeLock.safeAcquire() - val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_PAUSE -> { - wakeLock.safeRelease() mapView.onPause() } Lifecycle.Event.ON_RESUME -> { - wakeLock.safeAcquire() mapView.onResume() } @@ -166,10 +130,7 @@ internal fun rememberMapViewWithLifecycle( lifecycle.addObserver(observer) - onDispose { - lifecycle.removeObserver(observer) - wakeLock.safeRelease() - } + onDispose { lifecycle.removeObserver(observer) } } return mapView } diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt deleted file mode 100644 index 22eac8c02..000000000 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2025-2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map.component - -import androidx.compose.foundation.layout.size -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.map_style_selection -import org.meshtastic.core.ui.icon.Layers -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.theme.AppTheme - -@Composable -fun MapButton( - icon: ImageVector, - contentDescription: StringResource, - modifier: Modifier = Modifier, - onClick: () -> Unit = {}, -) { - MapButton( - icon = icon, - contentDescription = stringResource(contentDescription), - modifier = modifier, - onClick = onClick, - ) -} - -@Composable -fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { - FloatingActionButton(onClick = onClick, modifier = modifier) { - Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp)) - } -} - -@PreviewLightDark -@Composable -private fun MapButtonPreview() { - AppTheme { MapButton(icon = MeshtasticIcons.Layers, contentDescription = Res.string.map_style_selection) } -} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt index 668f17413..b7795180f 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt @@ -17,48 +17,38 @@ package org.meshtastic.app.map.node import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.meshtastic.app.map.addCopyright -import org.meshtastic.app.map.addPolyline -import org.meshtastic.app.map.addPositionMarkers -import org.meshtastic.app.map.addScaleBarOverlay -import org.meshtastic.app.map.model.CustomTileSource -import org.meshtastic.app.map.rememberMapViewWithLifecycle +import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.feature.map.node.NodeMapViewModel -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint - -private const val DEG_D = 1e-7 @Composable fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val density = LocalDensity.current - val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - val geoPoints = positionLogs.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) } - val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) } - val mapView = - rememberMapViewWithLifecycle( - applicationId = nodeMapViewModel.applicationId, - box = cameraView, - tileSource = CustomTileSource.getTileSource(nodeMapViewModel.mapStyleId), - ) + val node by nodeMapViewModel.node.collectAsStateWithLifecycle() + val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { mapView }, - update = { map -> - map.overlays.clear() - map.addCopyright() - map.addScaleBarOverlay(density) - - map.addPolyline(density, geoPoints) {} - map.addPositionMarkers(positionLogs) {} + Scaffold( + topBar = { + MainAppBar( + title = node?.user?.long_name ?: "", + ourNode = null, + showNodeChip = false, + canNavigateUp = true, + onNavigateUp = onNavigateUp, + actions = {}, + onClickChip = {}, + ) }, - ) + ) { paddingValues -> + NodeTrackOsmMap( + positions = positions, + applicationId = nodeMapViewModel.applicationId, + mapStyleId = nodeMapViewModel.mapStyleId, + modifier = Modifier.fillMaxSize().padding(paddingValues), + ) + } } 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 new file mode 100644 index 000000000..0178a498e --- /dev/null +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.map.node + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.feature.map.node.NodeMapViewModel +import org.meshtastic.proto.Position + +/** + * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain + * [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation + * ([NodeTrackOsmMap]). + */ +@Composable +fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = Modifier) { + val vm = koinViewModel() + vm.setDestNum(destNum) + NodeTrackOsmMap( + positions = positions, + applicationId = vm.applicationId, + mapStyleId = vm.mapStyleId, + modifier = modifier, + ) +} 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 new file mode 100644 index 000000000..64d207a6e --- /dev/null +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.map.node + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.app.map.MapViewModel +import org.meshtastic.app.map.addCopyright +import org.meshtastic.app.map.addPolyline +import org.meshtastic.app.map.addPositionMarkers +import org.meshtastic.app.map.addScaleBarOverlay +import org.meshtastic.app.map.component.MapControlsOverlay +import org.meshtastic.app.map.model.CustomTileSource +import org.meshtastic.app.map.rememberMapViewWithLifecycle +import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.util.GeoConstants.DEG_D +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.last_heard_filter_label +import org.meshtastic.feature.map.LastHeardFilter +import org.meshtastic.proto.Position +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import kotlin.math.roundToInt + +/** + * A focused OSMDroid map composable that renders **only** a node's position track — a dashed polyline with directional + * markers for each historical position. + * + * Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter] + * from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a + * minimal [MapControlsOverlay][org.meshtastic.app.map.component.MapControlsOverlay] with a track time filter slider so + * users can adjust the time range directly from the map. + * + * Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or + * location tracking. It is designed to be embedded inside the position-log adaptive layout. + */ +@Composable +fun NodeTrackOsmMap( + positions: List, + applicationId: String, + mapStyleId: Int, + modifier: Modifier = Modifier, + mapViewModel: MapViewModel = koinViewModel(), +) { + val density = LocalDensity.current + val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() + val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter + + val filteredPositions = + remember(positions, lastHeardTrackFilter) { + positions.filter { + lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds + } + } + + val geoPoints = + remember(filteredPositions) { + filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) } + } + val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) } + val mapView = + rememberMapViewWithLifecycle( + applicationId = applicationId, + box = cameraView, + tileSource = CustomTileSource.getTileSource(mapStyleId), + ) + + var filterMenuExpanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + AndroidView( + modifier = Modifier.matchParentSize(), + factory = { mapView }, + update = { map -> + map.overlays.clear() + map.addCopyright() + map.addScaleBarOverlay(density) + map.addPolyline(density, geoPoints) {} + map.addPositionMarkers(filteredPositions) {} + }, + ) + + // Track filter controls overlay + MapControlsOverlay( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), + onToggleFilterMenu = { filterMenuExpanded = true }, + filterDropdownContent = { + DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + val filterOptions = LastHeardFilter.entries + val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter) + var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } + + Text( + text = + stringResource( + Res.string.last_heard_filter_label, + stringResource(lastHeardTrackFilter.label), + ), + style = MaterialTheme.typography.labelLarge, + ) + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { + val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) + mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex]) + }, + valueRange = 0f..(filterOptions.size - 1).toFloat(), + steps = filterOptions.size - 2, + ) + } + } + }, + ) + } +} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt new file mode 100644 index 000000000..fcf1d47e9 --- /dev/null +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.map.traceroute + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.proto.Position + +/** + * Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation + * ([TracerouteOsmMap]). + */ +@Composable +fun TracerouteMap( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + onMappableCountChanged: (shown: Int, total: Int) -> Unit, + modifier: Modifier = Modifier, +) { + TracerouteOsmMap( + tracerouteOverlay = tracerouteOverlay, + tracerouteNodePositions = tracerouteNodePositions, + onMappableCountChanged = onMappableCountChanged, + modifier = modifier, + ) +} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt new file mode 100644 index 000000000..55b49154a --- /dev/null +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.app.map.traceroute + +import android.graphics.Paint +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.app.R +import org.meshtastic.app.map.MapViewModel +import org.meshtastic.app.map.addCopyright +import org.meshtastic.app.map.addScaleBarOverlay +import org.meshtastic.app.map.model.CustomTileSource +import org.meshtastic.app.map.model.MarkerWithLabel +import org.meshtastic.app.map.rememberMapViewWithLifecycle +import org.meshtastic.app.map.zoomIn +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS +import org.meshtastic.core.ui.theme.TracerouteColors +import org.meshtastic.core.ui.util.formatAgo +import org.meshtastic.feature.map.tracerouteNodeSelection +import org.meshtastic.proto.Position +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polyline +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin + +private const val TRACEROUTE_OFFSET_METERS = 100.0 +private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0 +private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5 + +/** + * A focused OSMDroid map composable that renders **only** traceroute visualization — node markers for each hop and + * forward/return offset polylines with auto-centering camera. + * + * Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any + * map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold. + */ +@Composable +fun TracerouteOsmMap( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + onMappableCountChanged: (shown: Int, total: Int) -> Unit, + modifier: Modifier = Modifier, + mapViewModel: MapViewModel = koinViewModel(), +) { + val context = LocalContext.current + val density = LocalDensity.current + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() + val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) } + + // Resolve which nodes to display for the traceroute + val tracerouteSelection = + remember(tracerouteOverlay, tracerouteNodePositions, nodes) { + mapViewModel.tracerouteNodeSelection( + tracerouteOverlay = tracerouteOverlay, + tracerouteNodePositions = tracerouteNodePositions, + nodes = nodes, + ) + } + val displayNodes = tracerouteSelection.nodesForMarkers + val nodeLookup = tracerouteSelection.nodeLookup + + // Report mappable count + LaunchedEffect(tracerouteOverlay, displayNodes) { + if (tracerouteOverlay != null) { + onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) + } + } + + // Compute polyline GeoPoints from node positions + val forwardPoints = + remember(tracerouteOverlay, nodeLookup) { + tracerouteOverlay?.forwardRoute?.mapNotNull { + nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } + } ?: emptyList() + } + val returnPoints = + remember(tracerouteOverlay, nodeLookup) { + tracerouteOverlay?.returnRoute?.mapNotNull { + nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } + } ?: emptyList() + } + + // Compute offset polylines for visual separation + val headingReferencePoints = + remember(forwardPoints, returnPoints) { + when { + forwardPoints.size >= 2 -> forwardPoints + returnPoints.size >= 2 -> returnPoints + else -> emptyList() + } + } + val forwardOffsetPoints = + remember(forwardPoints, headingReferencePoints) { + offsetPolyline( + points = forwardPoints, + offsetMeters = TRACEROUTE_OFFSET_METERS, + headingReferencePoints = headingReferencePoints, + sideMultiplier = 1.0, + ) + } + val returnOffsetPoints = + remember(returnPoints, headingReferencePoints) { + offsetPolyline( + points = returnPoints, + offsetMeters = TRACEROUTE_OFFSET_METERS, + headingReferencePoints = headingReferencePoints, + sideMultiplier = -1.0, + ) + } + + // Camera auto-center + var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) } + + // Build initial camera from all traceroute points + val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() } + val initialCameraView = + remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) } + + val mapView = + rememberMapViewWithLifecycle( + applicationId = mapViewModel.applicationId, + box = initialCameraView ?: BoundingBox(), + tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId), + ) + + // Center camera on traceroute bounds + LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) { + if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect + if (allPoints.isNotEmpty()) { + if (allPoints.size == 1) { + mapView.controller.setCenter(allPoints.first()) + mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM) + } else { + mapView.zoomToBoundingBox( + BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), + true, + ) + } + hasCentered = true + } + } + + AndroidView( + modifier = modifier, + factory = { mapView.apply { setDestroyMode(false) } }, + update = { map -> + map.overlays.clear() + map.addCopyright() + map.addScaleBarOverlay(density) + + // Render traceroute polylines + buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) } + + // Render simple node markers + displayNodes.forEach { node -> + val position = GeoPoint(node.latitude, node.longitude) + val marker = + MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}") + .apply { + id = node.user.id + title = node.user.long_name + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + this.position = position + icon = markerIcon + setNodeColors(node.colors) + } + map.overlays.add(marker) + } + + map.invalidate() + }, + ) +} + +private fun buildTraceroutePolylines( + forwardPoints: List, + returnPoints: List, + density: androidx.compose.ui.unit.Density, +): List { + val polylines = mutableListOf() + + fun buildPolyline(points: List, color: Int, strokeWidth: Float): Polyline = Polyline().apply { + setPoints(points) + outlinePaint.apply { + this.color = color + this.strokeWidth = strokeWidth + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + style = Paint.Style.STROKE + } + } + + forwardPoints + .takeIf { it.size >= 2 } + ?.let { points -> + polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() })) + } + returnPoints + .takeIf { it.size >= 2 } + ?.let { points -> + polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() })) + } + return polylines +} + +// --- Haversine offset math for OSMDroid (no SphericalUtil available) --- + +private fun Double.toRad(): Double = this * PI / 180.0 + +private fun bearingRad(from: GeoPoint, to: GeoPoint): Double { + val lat1 = from.latitude.toRad() + val lat2 = to.latitude.toRad() + val dLon = (to.longitude - from.longitude).toRad() + return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)) +} + +private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint { + val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS + val lat1 = latitude.toRad() + val lon1 = longitude.toRad() + val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad)) + val lon2 = + lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2)) + return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI) +} + +private fun offsetPolyline( + points: List, + offsetMeters: Double, + headingReferencePoints: List = points, + sideMultiplier: Double = 1.0, +): List { + val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points + if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points + + val headings = + headingPoints.mapIndexed { index, _ -> + when (index) { + 0 -> bearingRad(headingPoints[0], headingPoints[1]) + headingPoints.lastIndex -> + bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex]) + + else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1]) + } + } + + return points.mapIndexed { index, point -> + val heading = headings[index.coerceIn(0, headings.lastIndex)] + val perpendicularHeading = heading + (PI / 2 * sideMultiplier) + point.offsetPoint(perpendicularHeading, abs(offsetMeters)) + } +} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt index c228297a3..940c4ab5a 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt @@ -23,31 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.annotation.Single import org.meshtastic.core.ui.util.MapViewProvider +/** Google Maps implementation of [MapViewProvider]. */ @Single class GoogleMapViewProvider : MapViewProvider { @Composable - override fun MapView( - modifier: Modifier, - viewModel: Any, - navigateToNodeDetails: (Int) -> Unit, - focusedNodeNum: Int?, - nodeTracks: List?, - tracerouteOverlay: Any?, - tracerouteNodePositions: Map, - onTracerouteMappableCountChanged: (Int, Int) -> Unit, - waypointId: Int?, - ) { + override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) { val mapViewModel: MapViewModel = koinViewModel() LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) } org.meshtastic.app.map.MapView( modifier = modifier, mapViewModel = mapViewModel, navigateToNodeDetails = navigateToNodeDetails, - focusedNodeNum = focusedNodeNum, - nodeTracks = nodeTracks as? List, - tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions as? Map ?: emptyMap(), - onTracerouteMappableCountChanged = onTracerouteMappableCountChanged, ) } } 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 6330248aa..530fc0c7b 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -74,6 +74,7 @@ import com.google.android.gms.maps.model.JointType import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import com.google.maps.android.SphericalUtil +import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.ComposeMapColorScheme import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.MapEffect @@ -85,10 +86,13 @@ import com.google.maps.android.compose.MarkerComposable import com.google.maps.android.compose.MarkerInfoWindowComposable import com.google.maps.android.compose.Polyline import com.google.maps.android.compose.TileOverlay +import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState import com.google.maps.android.compose.widgets.ScaleBar +import com.google.maps.android.data.Layer import com.google.maps.android.data.geojson.GeoJsonLayer import com.google.maps.android.data.kml.KmlLayer +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.json.JSONObject @@ -97,13 +101,20 @@ import org.meshtastic.app.map.component.ClusterItemsListDialog import org.meshtastic.app.map.component.CustomMapLayersSheet import org.meshtastic.app.map.component.CustomTileProviderManagerSheet import org.meshtastic.app.map.component.EditWaypointDialog +import org.meshtastic.app.map.component.MapButton import org.meshtastic.app.map.component.MapControlsOverlay +import org.meshtastic.app.map.component.MapFilterDropdown +import org.meshtastic.app.map.component.MapTypeDropdown import org.meshtastic.app.map.component.NodeClusterMarkers +import org.meshtastic.app.map.component.NodeMapFilterDropdown import org.meshtastic.app.map.component.WaypointMarkers import org.meshtastic.app.map.model.NodeClusterItem import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Node +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.model.util.GeoConstants.DEG_D +import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG import org.meshtastic.core.model.util.metersIn import org.meshtastic.core.model.util.mpsToKmph import org.meshtastic.core.model.util.mpsToMph @@ -113,19 +124,23 @@ import org.meshtastic.core.resources.alt import org.meshtastic.core.resources.heading import org.meshtastic.core.resources.latitude import org.meshtastic.core.resources.longitude +import org.meshtastic.core.resources.manage_map_layers +import org.meshtastic.core.resources.map_tile_source import org.meshtastic.core.resources.position import org.meshtastic.core.resources.sats import org.meshtastic.core.resources.speed import org.meshtastic.core.resources.timestamp import org.meshtastic.core.resources.track_point import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.core.ui.icon.Layers +import org.meshtastic.core.ui.icon.Map import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.TripOrigin import org.meshtastic.core.ui.theme.TracerouteColors import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.formatPositionTime +import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.LastHeardFilter -import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.map.tracerouteNodeSelection import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.Position @@ -133,9 +148,30 @@ import org.meshtastic.proto.Waypoint import kotlin.math.abs import kotlin.math.max -private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f -private const val DEG_D = 1e-7 -private const val HEADING_DEG = 1e-5 +// region --- Map Mode --- + +/** + * Discriminated mode for [MapView] — replaces the original pile of nullable parameters with a type-safe sealed + * hierarchy. Each mode carries only the data it needs; the shared infrastructure (location tracking, tile providers, + * controls overlay) is available in every mode. + */ +sealed interface GoogleMapMode { + /** Standard map: node clusters, waypoints, custom layers, waypoint editing. */ + data object Main : GoogleMapMode + + /** Focused node position track: polyline + gradient markers for historical positions. */ + data class NodeTrack(val focusedNode: Node?, val positions: List) : GoogleMapMode + + /** Traceroute visualization: offset forward/return polylines + hop markers. */ + data class Traceroute( + val overlay: TracerouteOverlay?, + val nodePositions: Map, + val onMappableCountChanged: (shown: Int, total: Int) -> Unit, + ) : GoogleMapMode +} + +// endregion + private const val TRACEROUTE_OFFSET_METERS = 100.0 private const val TRACEROUTE_BOUNDS_PADDING_PX = 120 @@ -145,28 +181,22 @@ private const val TRACEROUTE_BOUNDS_PADDING_PX = 120 fun MapView( modifier: Modifier = Modifier, mapViewModel: MapViewModel = koinViewModel(), - navigateToNodeDetails: (Int) -> Unit, - focusedNodeNum: Int? = null, - nodeTracks: List? = null, - tracerouteOverlay: TracerouteOverlay? = null, - tracerouteNodePositions: Map = emptyMap(), - onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> }, + navigateToNodeDetails: (Int) -> Unit = {}, + mode: GoogleMapMode = GoogleMapMode.Main, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle() - val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle() - // Location permissions state + // --- Location permissions --- val locationPermissionsState = rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } - // Location tracking state + // --- Location tracking --- var isLocationTrackingEnabled by remember { mutableStateOf(false) } var followPhoneBearing by remember { mutableStateOf(false) } - // Effect to toggle location tracking after permission is granted LaunchedEffect(locationPermissionsState.allPermissionsGranted) { if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { isLocationTrackingEnabled = true @@ -174,9 +204,10 @@ fun MapView( } } + // --- File picker for map layers (Main mode) --- val filePickerLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { + if (result.resultCode == Activity.RESULT_OK) { result.data?.data?.let { uri -> val fileName = uri.getFileName(context) mapViewModel.addMapLayer(uri, fileName) @@ -184,6 +215,7 @@ fun MapView( } } + // --- UI state --- var mapFilterMenuExpanded by remember { mutableStateOf(false) } val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() @@ -195,16 +227,20 @@ fun MapView( var mapTypeMenuExpanded by remember { mutableStateOf(false) } var showCustomTileManagerSheet by remember { mutableStateOf(false) } - val cameraPositionState = mapViewModel.cameraPositionState + // --- Camera --- + // Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering. + val cameraPositionState = + if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState() - // Save camera position when it stops moving - LaunchedEffect(cameraPositionState.isMoving) { - if (!cameraPositionState.isMoving) { - mapViewModel.saveCameraPosition(cameraPositionState.position) + if (mode is GoogleMapMode.Main) { + LaunchedEffect(cameraPositionState.isMoving) { + if (!cameraPositionState.isMoving) { + mapViewModel.saveCameraPosition(cameraPositionState.position) + } } } - // Location tracking functionality + // --- FusedLocation --- val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } val locationCallback = remember { object : LocationCallback() { @@ -243,14 +279,12 @@ fun MapView( } } - // Start/stop location tracking based on state LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) { if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) { val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L) .setMinUpdateIntervalMillis(2000L) .build() - try { @Suppress("MissingPermission") fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null) @@ -267,20 +301,12 @@ fun MapView( DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } } + // --- Node & waypoint data --- val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf()) val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint } val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() - val tracerouteSelection = - remember(tracerouteOverlay, tracerouteNodePositions, allNodes) { - mapViewModel.tracerouteNodeSelection( - tracerouteOverlay = tracerouteOverlay, - tracerouteNodePositions = tracerouteNodePositions, - nodes = allNodes, - ) - } - val filteredNodes = allNodes .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num } @@ -290,30 +316,7 @@ fun MapView( node.num == ourNodeInfo?.num } - val displayNodes = - if (tracerouteOverlay != null) { - tracerouteSelection.nodesForMarkers - } else { - filteredNodes - } - LaunchedEffect(tracerouteOverlay, displayNodes) { - if (tracerouteOverlay != null) { - onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) - } - } - val myNodeNum = mapViewModel.myNodeNum - val nodeClusterItems = - displayNodes.map { node -> - val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D) - NodeClusterItem( - node = node, - nodePosition = latLng, - nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}", - nodeSnippet = "${node.user.long_name}", - myNodeNum = myNodeNum, - ) - } val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() val theme by mapViewModel.theme.collectAsStateWithLifecycle() val dark = @@ -323,20 +326,69 @@ fun MapView( AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme() else -> isSystemInDarkTheme() } - val mapColorScheme = - when (dark) { - true -> ComposeMapColorScheme.DARK - else -> ComposeMapColorScheme.LIGHT + val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT + + // --- Mode-specific data --- + // Node track: apply time filter + val sortedTrackPositions = + if (mode is GoogleMapMode.NodeTrack) { + val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter + remember(mode.positions, lastHeardTrackFilter) { + mode.positions + .filter { + lastHeardTrackFilter == LastHeardFilter.Any || + it.time > nowSeconds - lastHeardTrackFilter.seconds + } + .sortedBy { it.time } + } + } else { + emptyList() } - val tracerouteForwardPoints = - remember(tracerouteOverlay, displayNodes) { - val nodeLookup = displayNodes.associateBy { it.num } - tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList() + + // Traceroute: resolve node selection + polylines. Collected unconditionally per Compose rules + // (composable calls cannot be conditional), but only consumed in Traceroute mode. Uses all + // nodes, not just those with positions, so getNodeOrFallback can resolve metadata for hops + // whose positions come from snapshots. + val allNodesForTraceroute by mapViewModel.nodes.collectAsStateWithLifecycle(listOf()) + val tracerouteSelection = + if (mode is GoogleMapMode.Traceroute) { + remember(mode.overlay, mode.nodePositions, allNodesForTraceroute) { + mapViewModel.tracerouteNodeSelection( + tracerouteOverlay = mode.overlay, + tracerouteNodePositions = mode.nodePositions, + nodes = allNodesForTraceroute, + ) + } + } else { + null } - val tracerouteReturnPoints = - remember(tracerouteOverlay, displayNodes) { - val nodeLookup = displayNodes.associateBy { it.num } - tracerouteOverlay?.returnRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList() + val tracerouteDisplayNodes = tracerouteSelection?.nodesForMarkers ?: emptyList() + + if (mode is GoogleMapMode.Traceroute) { + LaunchedEffect(mode.overlay, tracerouteDisplayNodes) { + if (mode.overlay != null) { + mode.onMappableCountChanged(tracerouteDisplayNodes.size, mode.overlay.relatedNodeNums.size) + } + } + } + + val tracerouteForwardPoints: List = + if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) { + val nodeLookup = tracerouteSelection.nodeLookup + remember(mode.overlay, nodeLookup) { + mode.overlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList() + } + } else { + emptyList() + } + val tracerouteReturnPoints: List = + if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) { + val nodeLookup = tracerouteSelection.nodeLookup + remember(mode.overlay, nodeLookup) { + mode.overlay?.returnRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList() + } + } else { + emptyList() } val tracerouteHeadingReferencePoints = remember(tracerouteForwardPoints, tracerouteReturnPoints) { @@ -348,24 +400,64 @@ fun MapView( } val tracerouteForwardOffsetPoints = remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { - offsetPolyline( - points = tracerouteForwardPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = tracerouteHeadingReferencePoints, - sideMultiplier = 1.0, - ) + offsetPolyline(tracerouteForwardPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, 1.0) } val tracerouteReturnOffsetPoints = remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) { - offsetPolyline( - points = tracerouteReturnPoints, - offsetMeters = TRACEROUTE_OFFSET_METERS, - headingReferencePoints = tracerouteHeadingReferencePoints, - sideMultiplier = -1.0, - ) + offsetPolyline(tracerouteReturnPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, -1.0) } - var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) } + // Auto-centering for NodeTrack / Traceroute modes + var hasCentered by remember(mode) { mutableStateOf(false) } + + if (mode is GoogleMapMode.NodeTrack) { + LaunchedEffect(sortedTrackPositions, hasCentered) { + if (hasCentered || sortedTrackPositions.isEmpty()) return@LaunchedEffect + val points = sortedTrackPositions.map { it.toLatLng() } + val cameraUpdate = + if (points.size == 1) { + CameraUpdateFactory.newLatLngZoom(points.first(), max(cameraPositionState.position.zoom, 12f)) + } else { + val bounds = LatLngBounds.builder() + points.forEach { bounds.include(it) } + CameraUpdateFactory.newLatLngBounds(bounds.build(), 80) + } + try { + cameraPositionState.animate(cameraUpdate) + hasCentered = true + } catch (e: IllegalStateException) { + Logger.d { "Error centering track map: ${e.message}" } + } + } + } + + if (mode is GoogleMapMode.Traceroute) { + LaunchedEffect(mode.overlay, tracerouteForwardPoints, tracerouteReturnPoints) { + if (mode.overlay == null || hasCentered) return@LaunchedEffect + val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() + if (allPoints.isNotEmpty()) { + val cameraUpdate = + if (allPoints.size == 1) { + CameraUpdateFactory.newLatLngZoom( + allPoints.first(), + max(cameraPositionState.position.zoom, 12f), + ) + } else { + val bounds = LatLngBounds.builder() + allPoints.forEach { bounds.include(it) } + CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX) + } + try { + cameraPositionState.animate(cameraUpdate) + hasCentered = true + } catch (e: IllegalStateException) { + Logger.d { "Error centering traceroute overlay: ${e.message}" } + } + } + } + } + + // --- Tile & layers state --- var showLayersBottomSheet by remember { mutableStateOf(false) } val onAddLayerClicked = { @@ -388,45 +480,23 @@ fun MapView( val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) } val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) } - val effectiveGoogleMapType = - if (currentCustomTileProviderUrl != null) { - MapType.NONE - } else { - selectedGoogleMapType - } + val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType var showClusterItemsDialog by remember { mutableStateOf?>(null) } + // --- Keep screen on while location tracking --- LaunchedEffect(isLocationTrackingEnabled) { val activity = context as? Activity ?: return@LaunchedEffect val window = activity.window - if (isLocationTrackingEnabled) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } - LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) { - if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect - val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() - if (allPoints.isNotEmpty()) { - val cameraUpdate = - if (allPoints.size == 1) { - CameraUpdateFactory.newLatLngZoom(allPoints.first(), max(cameraPositionState.position.zoom, 12f)) - } else { - val bounds = LatLngBounds.builder() - allPoints.forEach { bounds.include(it) } - CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX) - } - try { - cameraPositionState.animate(cameraUpdate) - hasCenteredTraceroute = true - } catch (e: IllegalStateException) { - Logger.d { "Error centering traceroute overlay: ${e.message}" } - } - } - } + + // --- Main UI --- + val isMainMode = mode is GoogleMapMode.Main Box(modifier = modifier) { GoogleMap( @@ -436,12 +506,12 @@ fun MapView( uiSettings = MapUiSettings( zoomControlsEnabled = true, - mapToolbarEnabled = true, + mapToolbarEnabled = isMainMode, compassEnabled = false, myLocationButtonEnabled = false, rotationGesturesEnabled = true, scrollGesturesEnabled = true, - tiltGesturesEnabled = true, + tiltGesturesEnabled = isMainMode, zoomGesturesEnabled = true, ), properties = @@ -450,16 +520,16 @@ fun MapView( isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, ), onMapLongClick = { latLng -> - if (isConnected) { - val newWaypoint = + if (isMainMode && isConnected) { + editingWaypoint = Waypoint( latitude_i = (latLng.latitude / DEG_D).toInt(), longitude_i = (latLng.longitude / DEG_D).toInt(), ) - editingWaypoint = newWaypoint } }, ) { + // Custom tile overlay (all modes) key(currentCustomTileProviderUrl) { currentCustomTileProviderUrl?.let { url -> val config = @@ -472,180 +542,143 @@ fun MapView( } } - if (tracerouteForwardPoints.size >= 2) { - Polyline( - points = tracerouteForwardOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.OutgoingRoute, - width = 9f, - zIndex = 3.0f, - ) - } - if (tracerouteReturnPoints.size >= 2) { - Polyline( - points = tracerouteReturnOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.ReturnRoute, - width = 7f, - zIndex = 2.5f, - ) - } - - if (nodeTracks != null && focusedNodeNum != null) { - val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter - val timeFilteredPositions = - nodeTracks.filter { - lastHeardTrackFilter == LastHeardFilter.Any || - it.time > nowSeconds - lastHeardTrackFilter.seconds - } - val sortedPositions = timeFilteredPositions.sortedBy { it.time } - allNodes - .find { it.num == focusedNodeNum } - ?.let { focusedNode -> - sortedPositions.forEachIndexed { index, position -> - key(position.time) { - val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) - val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1)) - val color = Color(focusedNode.colors.second).copy(alpha = alpha) - val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite - val activeNodeZIndex = if (isHighPriority) 5f else 4f - - if (index == sortedPositions.lastIndex) { - MarkerComposable( - state = markerState, - zIndex = activeNodeZIndex, - alpha = if (isHighPriority) 1.0f else 0.9f, - ) { - NodeChip(node = focusedNode) - } - } else { - MarkerInfoWindowComposable( - state = markerState, - title = stringResource(Res.string.position), - snippet = formatAgo(position.time), - zIndex = 1f + alpha, - infoContent = { - PositionInfoWindowContent(position = position, displayUnits = displayUnits) - }, - ) { - Icon( - imageVector = MeshtasticIcons.TripOrigin, - contentDescription = stringResource(Res.string.track_point), - tint = color, - ) - } - } - } - } - - if (sortedPositions.size > 1) { - val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) - segments.forEachIndexed { index, segmentPoints -> - val alpha = (index.toFloat() / (segments.size.toFloat() - 1)) - Polyline( - points = segmentPoints.map { it.toLatLng() }, - jointType = JointType.ROUND, - color = Color(focusedNode.colors.second).copy(alpha = alpha), - width = 8f, - zIndex = 0.6f, + when (mode) { + is GoogleMapMode.Main -> + MainMapContent( + nodeClusterItems = + filteredNodes.map { node -> + val latLng = + LatLng( + (node.position.latitude_i ?: 0) * DEG_D, + (node.position.longitude_i ?: 0) * DEG_D, ) - } - } + NodeClusterItem( + node = node, + nodePosition = latLng, + nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}", + nodeSnippet = "${node.user.long_name}", + myNodeNum = myNodeNum, + ) + }, + mapFilterState = mapFilterState, + navigateToNodeDetails = navigateToNodeDetails, + displayableWaypoints = displayableWaypoints, + myNodeNum = myNodeNum, + isConnected = isConnected, + onEditWaypointRequest = { editingWaypoint = it }, + selectedWaypointId = selectedWaypointId, + mapLayers = mapLayers, + mapViewModel = mapViewModel, + cameraPositionState = cameraPositionState, + coroutineScope = coroutineScope, + onShowClusterItemsDialog = { showClusterItemsDialog = it }, + ) + + is GoogleMapMode.NodeTrack -> { + val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle() + if (mode.focusedNode != null && sortedTrackPositions.isNotEmpty()) { + NodeTrackOverlay( + focusedNode = mode.focusedNode, + sortedPositions = sortedTrackPositions, + displayUnits = displayUnits, + myNodeNum = myNodeNum, + ) } - } else { - NodeClusterMarkers( - nodeClusterItems = nodeClusterItems, - mapFilterState = mapFilterState, - navigateToNodeDetails = navigateToNodeDetails, - onClusterClick = { cluster -> - val items = cluster.items.toList() - val allSameLocation = items.size > 1 && items.all { it.position == items.first().position } + } - if (allSameLocation) { - showClusterItemsDialog = items - } else { - val bounds = LatLngBounds.builder() - cluster.items.forEach { bounds.include(it.position) } - coroutineScope.launch { - cameraPositionState.animate( - CameraUpdateFactory.newCameraPosition( - CameraPosition.Builder() - .target(bounds.build().center) - .zoom(cameraPositionState.position.zoom + 1) - .build(), - ), - ) - } - Logger.d { "Cluster clicked! $cluster" } - } - true - }, - ) + is GoogleMapMode.Traceroute -> + TracerouteMapContent( + forwardOffsetPoints = tracerouteForwardOffsetPoints, + returnOffsetPoints = tracerouteReturnOffsetPoints, + forwardPointCount = tracerouteForwardPoints.size, + returnPointCount = tracerouteReturnPoints.size, + displayNodes = tracerouteDisplayNodes, + ) } - - WaypointMarkers( - displayableWaypoints = displayableWaypoints, - mapFilterState = mapFilterState, - myNodeNum = mapViewModel.myNodeNum ?: 0, - isConnected = isConnected, - unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap, - onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit }, - selectedWaypointId = selectedWaypointId, - ) - - mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } } } + // Scale bar ScaleBar( cameraPositionState = cameraPositionState, - modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp), + modifier = Modifier.align(Alignment.BottomStart).padding(bottom = if (isMainMode) 48.dp else 16.dp), ) - editingWaypoint?.let { waypointToEdit -> - EditWaypointDialog( - waypoint = waypointToEdit, - onSendClicked = { updatedWp -> - var finalWp = updatedWp - if (updatedWp.id == 0) { - finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0) - } - if ((updatedWp.icon ?: 0) == 0) { - finalWp = finalWp.copy(icon = 0x1F4CD) - } - mapViewModel.sendWaypoint(finalWp) - editingWaypoint = null - }, - onDeleteClicked = { wpToDelete -> - if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) { - val deleteMarkerWp = wpToDelete.copy(expire = 1) - mapViewModel.sendWaypoint(deleteMarkerWp) - } - mapViewModel.deleteWaypoint(wpToDelete.id) - editingWaypoint = null - }, - onDismissRequest = { editingWaypoint = null }, - ) + // Waypoint edit dialog (Main mode only) + if (isMainMode) { + editingWaypoint?.let { waypointToEdit -> + EditWaypointDialog( + waypoint = waypointToEdit, + onSendClicked = { updatedWp -> + var finalWp = updatedWp + if (updatedWp.id == 0) { + finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0) + } + if ((updatedWp.icon ?: 0) == 0) { + finalWp = finalWp.copy(icon = 0x1F4CD) + } + mapViewModel.sendWaypoint(finalWp) + editingWaypoint = null + }, + onDeleteClicked = { wpToDelete -> + if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) { + mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1)) + } + mapViewModel.deleteWaypoint(wpToDelete.id) + editingWaypoint = null + }, + onDismissRequest = { editingWaypoint = null }, + ) + } } + // Controls overlay val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible } val showRefresh = visibleNetworkLayers.isNotEmpty() val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing } MapControlsOverlay( modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - mapFilterMenuExpanded = mapFilterMenuExpanded, - onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false }, - onToggleMapFilterMenu = { mapFilterMenuExpanded = true }, - mapViewModel = mapViewModel, - mapTypeMenuExpanded = mapTypeMenuExpanded, - onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false }, - onToggleMapTypeMenu = { mapTypeMenuExpanded = true }, - onManageLayersClicked = { showLayersBottomSheet = true }, - onManageCustomTileProvidersClicked = { - mapTypeMenuExpanded = false - showCustomTileManagerSheet = true + onToggleFilterMenu = { mapFilterMenuExpanded = true }, + filterDropdownContent = { + if (mode is GoogleMapMode.NodeTrack) { + NodeMapFilterDropdown( + expanded = mapFilterMenuExpanded, + onDismissRequest = { mapFilterMenuExpanded = false }, + mapViewModel = mapViewModel, + ) + } else { + MapFilterDropdown( + expanded = mapFilterMenuExpanded, + onDismissRequest = { mapFilterMenuExpanded = false }, + mapViewModel = mapViewModel, + ) + } + }, + mapTypeContent = { + Box { + MapButton( + icon = MeshtasticIcons.Map, + contentDescription = stringResource(Res.string.map_tile_source), + onClick = { mapTypeMenuExpanded = true }, + ) + MapTypeDropdown( + expanded = mapTypeMenuExpanded, + onDismissRequest = { mapTypeMenuExpanded = false }, + mapViewModel = mapViewModel, + onManageCustomTileProvidersClicked = { + mapTypeMenuExpanded = false + showCustomTileManagerSheet = true + }, + ) + } + }, + layersContent = { + MapButton( + icon = MeshtasticIcons.Layers, + contentDescription = stringResource(Res.string.manage_map_layers), + onClick = { showLayersBottomSheet = true }, + ) }, - isNodeMap = focusedNodeNum != null, isLocationTrackingEnabled = isLocationTrackingEnabled, onToggleLocationTracking = { if (locationPermissionsState.allPermissionsGranted) { @@ -681,6 +714,8 @@ fun MapView( onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() }, ) } + + // --- Bottom sheets & dialogs --- if (showLayersBottomSheet) { ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) { CustomMapLayersSheet( @@ -710,116 +745,138 @@ fun MapView( } } +// region --- Main Map Content --- + +@Suppress("LongParameterList") +@OptIn(MapsComposeExperimentalApi::class) @Composable -private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) { - val context = LocalContext.current - var currentLayer by remember { mutableStateOf(null) } - - MapEffect(layerItem.id, layerItem.isRefreshing) { map -> - // Cleanup old layer if we're reloading - currentLayer?.safeRemoveLayerFromMap() - currentLayer = null - - val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect - val layer = - try { - when (layerItem.layerType) { - LayerType.KML -> KmlLayer(map, inputStream, context) - LayerType.GEOJSON -> - GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })) +private fun MainMapContent( + nodeClusterItems: List, + mapFilterState: MapFilterState, + navigateToNodeDetails: (Int) -> Unit, + displayableWaypoints: List, + myNodeNum: Int?, + isConnected: Boolean, + onEditWaypointRequest: (Waypoint) -> Unit, + selectedWaypointId: Int?, + mapLayers: List, + mapViewModel: MapViewModel, + cameraPositionState: CameraPositionState, + coroutineScope: CoroutineScope, + onShowClusterItemsDialog: (List?) -> Unit, +) { + NodeClusterMarkers( + nodeClusterItems = nodeClusterItems, + mapFilterState = mapFilterState, + navigateToNodeDetails = navigateToNodeDetails, + onClusterClick = { cluster -> + val items = cluster.items.toList() + val allSameLocation = items.size > 1 && items.all { it.position == items.first().position } + if (allSameLocation) { + onShowClusterItemsDialog(items) + } else { + val bounds = LatLngBounds.builder() + cluster.items.forEach { bounds.include(it.position) } + coroutineScope.launch { + cameraPositionState.animate( + CameraUpdateFactory.newCameraPosition( + CameraPosition.Builder() + .target(bounds.build().center) + .zoom(cameraPositionState.position.zoom + 1) + .build(), + ), + ) } - } catch (e: Exception) { - Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" } - null + Logger.d { "Cluster clicked! $cluster" } } + true + }, + ) - layer?.let { - if (layerItem.isVisible) { - it.safeAddLayerToMap() - } - currentLayer = it - } - } + WaypointMarkers( + displayableWaypoints = displayableWaypoints, + mapFilterState = mapFilterState, + myNodeNum = myNodeNum ?: 0, + isConnected = isConnected, + unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap, + onEditWaypointRequest = onEditWaypointRequest, + selectedWaypointId = selectedWaypointId, + ) - DisposableEffect(layerItem.id) { - onDispose { - currentLayer?.safeRemoveLayerFromMap() - currentLayer = null - } - } - - // Handle visibility changes without reloading the whole layer if possible, - // though KmlLayer.addLayerToMap() / removeLayerFromMap() is what we have. - LaunchedEffect(layerItem.isVisible) { - val layer = currentLayer ?: return@LaunchedEffect - if (layerItem.isVisible) { - layer.safeAddLayerToMap() - } else { - layer.safeRemoveLayerFromMap() - } - } + mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } } } -private fun com.google.maps.android.data.Layer.safeRemoveLayerFromMap() { - try { - removeLayerFromMap() - } catch (e: Exception) { - // Log it and ignore. This specifically handles a NullPointerException in - // KmlRenderer.hasNestedContainers which can occur when disposing layers. - Logger.withTag("MapView").e(e) { "Error removing map layer" } - } -} +// endregion -private fun com.google.maps.android.data.Layer.safeAddLayerToMap() { - try { - if (!isLayerOnMap) { - addLayerToMap() - } - } catch (e: Exception) { - Logger.withTag("MapView").e(e) { "Error adding map layer" } - } -} +// region --- Node Track Overlay --- -internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try { - String(Character.toChars(unicodeCodePoint)) -} catch (e: IllegalArgumentException) { - Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" } - "\uD83D\uDCCD" -} +/** + * 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. + */ +@OptIn(MapsComposeExperimentalApi::class) +@Composable +private fun NodeTrackOverlay( + focusedNode: Node, + sortedPositions: List, + displayUnits: DisplayUnits, + myNodeNum: Int?, +) { + val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite + val activeNodeZIndex = if (isHighPriority) 5f else 4f -internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor { - val unicodeEmoji = convertIntToEmoji(icon) - val paint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = 64f - color = android.graphics.Color.BLACK - textAlign = Paint.Align.CENTER - } + sortedPositions.forEachIndexed { index, position -> + key(position.time) { + val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) + val alpha = + if (sortedPositions.size > 1) { + index.toFloat() / (sortedPositions.size.toFloat() - 1) + } else { + 1f + } + val color = Color(focusedNode.colors.second).copy(alpha = alpha) - val baseline = -paint.ascent() - val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt() - val height = (baseline + paint.descent() + 0.5f).toInt() - val image = createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888) - val canvas = Canvas(image) - canvas.drawText(unicodeEmoji, width / 2f, baseline, paint) - - return BitmapDescriptorFactory.fromBitmap(image) -} - -@Suppress("NestedBlockDepth") -fun Uri.getFileName(context: android.content.Context): String { - var name = this.lastPathSegment ?: "layer_$nowMillis" - if (this.scheme == "content") { - context.contentResolver.query(this, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (displayNameIndex != -1) { - name = cursor.getString(displayNameIndex) + if (index == sortedPositions.lastIndex) { + MarkerComposable( + state = markerState, + zIndex = activeNodeZIndex, + alpha = if (isHighPriority) 1.0f else 0.9f, + ) { + NodeChip(node = focusedNode) + } + } else { + MarkerInfoWindowComposable( + state = markerState, + title = stringResource(Res.string.position), + snippet = formatAgo(position.time), + zIndex = 1f + alpha, + infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) }, + ) { + Icon( + imageVector = MeshtasticIcons.TripOrigin, + contentDescription = stringResource(Res.string.track_point), + tint = color, + ) } } } } - return name + + // Gradient polyline segments + if (sortedPositions.size > 1) { + val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) + segments.forEachIndexed { index, segmentPoints -> + val alpha = index.toFloat() / (segments.size.toFloat() - 1) + Polyline( + points = segmentPoints.map { it.toLatLng() }, + jointType = JointType.ROUND, + color = Color(focusedNode.colors.second).copy(alpha = alpha), + width = 8f, + zIndex = 0.6f, + ) + } + } } @Composable @@ -840,26 +897,20 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayU label = stringResource(Res.string.latitude), value = "%.5f".format((position.latitude_i ?: 0) * DEG_D), ) - PositionRow( label = stringResource(Res.string.longitude), value = "%.5f".format((position.longitude_i ?: 0) * DEG_D), ) - - PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view?.toString() ?: "") - + PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view.toString()) PositionRow( label = stringResource(Res.string.alt), value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits), ) - PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits)) - PositionRow( label = stringResource(Res.string.heading), value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), ) - PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime()) } } @@ -869,24 +920,53 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayU private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String { val speedInMps = position.ground_speed ?: 0 val mpsText = "%d m/s".format(speedInMps) - val speedText = - if (speedInMps > 10) { - when (displayUnits) { - DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph()) - DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph()) - else -> mpsText - } - } else { - mpsText + return if (speedInMps > 10) { + when (displayUnits) { + DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph()) + DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph()) + else -> mpsText } - return speedText + } else { + mpsText + } } -internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) +// endregion -private fun Node.toLatLng(): LatLng? = this.position.toLatLng() +// region --- Traceroute Map Content --- -private fun Waypoint.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) +@OptIn(MapsComposeExperimentalApi::class) +@Composable +private fun TracerouteMapContent( + forwardOffsetPoints: List, + returnOffsetPoints: List, + forwardPointCount: Int, + returnPointCount: Int, + displayNodes: List, +) { + if (forwardPointCount >= 2) { + Polyline( + points = forwardOffsetPoints, + jointType = JointType.ROUND, + color = TracerouteColors.OutgoingRoute, + width = 9f, + zIndex = 3.0f, + ) + } + if (returnPointCount >= 2) { + Polyline( + points = returnOffsetPoints, + jointType = JointType.ROUND, + color = TracerouteColors.ReturnRoute, + width = 7f, + zIndex = 2.5f, + ) + } + displayNodes.forEach { node -> + val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng()) + MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) } + } +} private fun offsetPolyline( points: List, @@ -917,3 +997,111 @@ private fun offsetPolyline( SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading) } } + +// endregion + +// region --- Map Layers --- + +@Composable +private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) { + val context = LocalContext.current + var currentLayer by remember { mutableStateOf(null) } + + MapEffect(layerItem.id, layerItem.isRefreshing) { map -> + currentLayer?.safeRemoveLayerFromMap() + currentLayer = null + val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect + val layer = + try { + when (layerItem.layerType) { + LayerType.KML -> KmlLayer(map, inputStream, context) + LayerType.GEOJSON -> + GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })) + } + } catch (e: Exception) { + Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" } + null + } + layer?.let { + if (layerItem.isVisible) it.safeAddLayerToMap() + currentLayer = it + } + } + + DisposableEffect(layerItem.id) { + onDispose { + currentLayer?.safeRemoveLayerFromMap() + currentLayer = null + } + } + + LaunchedEffect(layerItem.isVisible) { + val layer = currentLayer ?: return@LaunchedEffect + if (layerItem.isVisible) layer.safeAddLayerToMap() else layer.safeRemoveLayerFromMap() + } +} + +private fun Layer.safeRemoveLayerFromMap() { + try { + removeLayerFromMap() + } catch (e: Exception) { + Logger.withTag("MapView").e(e) { "Error removing map layer" } + } +} + +private fun Layer.safeAddLayerToMap() { + try { + if (!isLayerOnMap) addLayerToMap() + } catch (e: Exception) { + Logger.withTag("MapView").e(e) { "Error adding map layer" } + } +} + +// endregion + +// region --- Utilities --- + +internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try { + String(Character.toChars(unicodeCodePoint)) +} catch (e: IllegalArgumentException) { + Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" } + "\uD83D\uDCCD" +} + +internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor { + val unicodeEmoji = convertIntToEmoji(icon) + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = 64f + color = android.graphics.Color.BLACK + textAlign = Paint.Align.CENTER + } + val baseline = -paint.ascent() + val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt() + val height = (baseline + paint.descent() + 0.5f).toInt() + val image = createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888) + val canvas = Canvas(image) + canvas.drawText(unicodeEmoji, width / 2f, baseline, paint) + return BitmapDescriptorFactory.fromBitmap(image) +} + +@Suppress("NestedBlockDepth") +fun Uri.getFileName(context: android.content.Context): String { + var name = this.lastPathSegment ?: "layer_$nowMillis" + if (this.scheme == "content") { + context.contentResolver.query(this, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (displayNameIndex != -1) { + name = cursor.getString(displayNameIndex) + } + } + } + } + return name +} + +/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */ +internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D) + +// endregion diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt index 9d9f79ec2..d8e29120e 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt @@ -42,9 +42,9 @@ import org.meshtastic.core.resources.only_favorites import org.meshtastic.core.resources.show_precision_circle import org.meshtastic.core.resources.show_waypoints import org.meshtastic.core.ui.icon.Favorite +import org.meshtastic.core.ui.icon.Lens import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Place -import org.meshtastic.core.ui.icon.RadioButtonUnchecked +import org.meshtastic.core.ui.icon.PinDrop import org.meshtastic.feature.map.LastHeardFilter import kotlin.math.roundToInt @@ -73,7 +73,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, onClick = { mapViewModel.toggleShowWaypointsOnMap() }, leadingIcon = { Icon( - imageVector = MeshtasticIcons.Place, + imageVector = MeshtasticIcons.PinDrop, contentDescription = stringResource(Res.string.show_waypoints), ) }, @@ -89,7 +89,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, leadingIcon = { Icon( - imageVector = MeshtasticIcons.RadioButtonUnchecked, // Placeholder icon + imageVector = MeshtasticIcons.Lens, contentDescription = stringResource(Res.string.show_precision_circle), ) }, diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt index fdc5ee262..d4a53dcc4 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt @@ -25,14 +25,13 @@ import com.google.android.gms.maps.model.LatLng import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberUpdatedMarkerState import kotlinx.coroutines.launch +import org.meshtastic.core.model.util.GeoConstants.DEG_D import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.locked import org.meshtastic.core.ui.util.showToast import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.proto.Waypoint -private const val DEG_D = 1e-7 - @Composable fun WaypointMarkers( displayableWaypoints: List, diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt index f6691b5ce..fa17fedbf 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt @@ -16,13 +16,14 @@ */ package org.meshtastic.app.map.node -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.meshtastic.app.map.GoogleMapMode import org.meshtastic.app.map.MapView import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.feature.map.node.NodeMapViewModel @@ -31,7 +32,6 @@ import org.meshtastic.feature.map.node.NodeMapViewModel fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { val node by nodeMapViewModel.node.collectAsStateWithLifecycle() val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - val destNum = node?.num Scaffold( topBar = { @@ -46,8 +46,9 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) ) }, ) { paddingValues -> - Box(modifier = Modifier.padding(paddingValues)) { - MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {}) - } + MapView( + modifier = Modifier.fillMaxSize().padding(paddingValues), + mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions), + ) } } 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 new file mode 100644 index 000000000..513957c61 --- /dev/null +++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.map.node + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.app.map.GoogleMapMode +import org.meshtastic.app.map.MapView +import org.meshtastic.feature.map.node.NodeMapViewModel +import org.meshtastic.proto.Position + +/** + * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a + * [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode, + * which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track + * filter). + */ +@Composable +fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = Modifier) { + val vm = koinViewModel() + vm.setDestNum(destNum) + val focusedNode by vm.node.collectAsStateWithLifecycle() + MapView(modifier = modifier, mode = GoogleMapMode.NodeTrack(focusedNode = focusedNode, positions = positions)) +} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt new file mode 100644 index 000000000..d725537c8 --- /dev/null +++ b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.app.map.traceroute + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.meshtastic.app.map.GoogleMapMode +import org.meshtastic.app.map.MapView +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.proto.Position + +/** + * Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute] + * mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay). + */ +@Composable +fun TracerouteMap( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + onMappableCountChanged: (shown: Int, total: Int) -> Unit, + modifier: Modifier = Modifier, +) { + MapView( + modifier = modifier, + mode = + GoogleMapMode.Traceroute( + overlay = tracerouteOverlay, + nodePositions = tracerouteNodePositions, + onMappableCountChanged = onMappableCountChanged, + ), + ) +} diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 8b3e85b9c..342b845dd 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -69,14 +69,24 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalInlineMapProvider +import org.meshtastic.core.ui.util.LocalMapMainScreenProvider import org.meshtastic.core.ui.util.LocalMapViewProvider import org.meshtastic.core.ui.util.LocalNfcScannerProvider import org.meshtastic.core.ui.util.LocalNfcScannerSupported +import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider +import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider +import org.meshtastic.core.ui.util.LocalTracerouteMapProvider +import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.intro.AppIntroductionScreen import org.meshtastic.feature.intro.IntroViewModel +import org.meshtastic.feature.map.MapScreen +import org.meshtastic.feature.map.SharedMapViewModel +import org.meshtastic.feature.map.node.NodeMapViewModel +import org.meshtastic.feature.node.metrics.MetricsViewModel +import org.meshtastic.feature.node.metrics.TracerouteMapScreen class MainActivity : ComponentActivity() { private val model: UIViewModel by viewModel() @@ -164,32 +174,42 @@ class MainActivity : ComponentActivity() { LocalAnalyticsIntroProvider provides { AnalyticsIntro() }, LocalMapViewProvider provides getMapViewProvider(), LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) }, + LocalNodeTrackMapProvider provides + { destNum, positions, modifier -> + org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier) + }, LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(), - org.meshtastic.core.ui.util.LocalNodeMapScreenProvider provides + LocalTracerouteMapProvider provides + { overlay, nodePositions, onMappableCountChanged, modifier -> + org.meshtastic.app.map.traceroute.TracerouteMap( + tracerouteOverlay = overlay, + tracerouteNodePositions = nodePositions, + onMappableCountChanged = onMappableCountChanged, + modifier = modifier, + ) + }, + LocalNodeMapScreenProvider provides { destNum, onNavigateUp -> - val vm = koinViewModel() + val vm = koinViewModel() vm.setDestNum(destNum) org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp) }, - org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides + LocalTracerouteMapScreenProvider provides { destNum, requestId, logUuid, onNavigateUp -> - val metricsViewModel = - koinViewModel { - org.koin.core.parameter.parametersOf(destNum) - } + val metricsViewModel = koinViewModel { parametersOf(destNum) } metricsViewModel.setNodeId(destNum) - org.meshtastic.feature.node.metrics.TracerouteMapScreen( + TracerouteMapScreen( metricsViewModel = metricsViewModel, requestId = requestId, logUuid = logUuid, onNavigateUp = onNavigateUp, ) }, - org.meshtastic.core.ui.util.LocalMapMainScreenProvider provides + LocalMapMainScreenProvider provides { onClickNodeChip, navigateToNodeDetails, waypointId -> - val viewModel = koinViewModel() - org.meshtastic.feature.map.MapScreen( + val viewModel = koinViewModel() + MapScreen( viewModel = viewModel, onClickNodeChip = onClickNodeChip, navigateToNodeDetails = navigateToNodeDetails, diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapButton.kt b/app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt similarity index 90% rename from app/src/google/kotlin/org/meshtastic/app/map/component/MapButton.kt rename to app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt index 0d5a79cdb..997d7d08b 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapButton.kt +++ b/app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt @@ -24,13 +24,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +/** + * A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance + * across both Google and F-Droid flavors. + */ @Composable fun MapButton( - modifier: Modifier = Modifier, icon: ImageVector, - iconTint: Color? = null, contentDescription: String, onClick: () -> Unit, + modifier: Modifier = Modifier, + iconTint: Color? = null, ) { FilledIconButton(onClick = onClick, modifier = modifier) { Icon( diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt b/app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt similarity index 56% rename from app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt rename to app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt index 4f0b1afa3..74f08e07f 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt +++ b/app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt @@ -27,17 +27,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.manage_map_layers import org.meshtastic.core.resources.map_filter -import org.meshtastic.core.resources.map_tile_source import org.meshtastic.core.resources.orient_north import org.meshtastic.core.resources.refresh import org.meshtastic.core.resources.toggle_my_position -import org.meshtastic.core.ui.icon.Layers import org.meshtastic.core.ui.icon.LocationDisabled -import org.meshtastic.core.ui.icon.Map import org.meshtastic.core.ui.icon.MapCompass import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MyLocation @@ -45,77 +40,58 @@ import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.icon.Tune import org.meshtastic.core.ui.theme.StatusColors.StatusRed +/** + * Shared map controls overlay used by both Google and F-Droid map views. Provides compass, filter button, location + * tracking button, and optional slots for flavor-specific content (map type selector, layers, refresh). + * + * @param onToggleFilterMenu Callback to open/close the filter dropdown. + * @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — typically a + * `DropdownMenu` with filter options. + * @param mapTypeContent Optional composable for a map type selector button + dropdown. Google flavor provides map type + * and custom tile options; F-Droid provides a tile source selector. + * @param layersContent Optional composable for a layers management button. + * @param showRefresh Whether to show a refresh button (e.g., for network map layers). + * @param isRefreshing Whether a refresh is currently in progress. + * @param onRefresh Callback when the refresh button is clicked. + */ +@Suppress("LongParameterList") @Composable fun MapControlsOverlay( + onToggleFilterMenu: () -> Unit, modifier: Modifier = Modifier, - mapFilterMenuExpanded: Boolean, - onMapFilterMenuDismissRequest: () -> Unit, - onToggleMapFilterMenu: () -> Unit, - mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown - mapTypeMenuExpanded: Boolean, - onMapTypeMenuDismissRequest: () -> Unit, - onToggleMapTypeMenu: () -> Unit, - onManageLayersClicked: () -> Unit, - onManageCustomTileProvidersClicked: () -> Unit, // New parameter - isNodeMap: Boolean, - // Location tracking parameters - isLocationTrackingEnabled: Boolean = false, - onToggleLocationTracking: () -> Unit = {}, bearing: Float = 0f, onCompassClick: () -> Unit = {}, - followPhoneBearing: Boolean, + followPhoneBearing: Boolean = false, + filterDropdownContent: @Composable () -> Unit = {}, + mapTypeContent: @Composable () -> Unit = {}, + layersContent: @Composable () -> Unit = {}, + isLocationTrackingEnabled: Boolean = false, + onToggleLocationTracking: () -> Unit = {}, showRefresh: Boolean = false, isRefreshing: Boolean = false, onRefresh: () -> Unit = {}, ) { Row(modifier = modifier) { + // Compass CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing) - if (isNodeMap) { + + // Filter button + dropdown + Box { MapButton( icon = MeshtasticIcons.Tune, contentDescription = stringResource(Res.string.map_filter), - onClick = onToggleMapFilterMenu, + onClick = onToggleFilterMenu, ) - NodeMapFilterDropdown( - expanded = mapFilterMenuExpanded, - onDismissRequest = onMapFilterMenuDismissRequest, - mapViewModel = mapViewModel, - ) - } else { - Box { - MapButton( - icon = MeshtasticIcons.Tune, - contentDescription = stringResource(Res.string.map_filter), - onClick = onToggleMapFilterMenu, - ) - MapFilterDropdown( - expanded = mapFilterMenuExpanded, - onDismissRequest = onMapFilterMenuDismissRequest, - mapViewModel = mapViewModel, - ) - } + filterDropdownContent() } - Box { - MapButton( - icon = MeshtasticIcons.Map, - contentDescription = stringResource(Res.string.map_tile_source), - onClick = onToggleMapTypeMenu, - ) - MapTypeDropdown( - expanded = mapTypeMenuExpanded, - onDismissRequest = onMapTypeMenuDismissRequest, - mapViewModel = mapViewModel, // Pass mapViewModel - onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback - ) - } + // Map type selector (flavor-specific) + mapTypeContent() - MapButton( - icon = MeshtasticIcons.Layers, - contentDescription = stringResource(Res.string.manage_map_layers), - onClick = onManageLayersClicked, - ) + // Layers button (flavor-specific) + layersContent() + // Refresh button (optional) if (showRefresh) { if (isRefreshing) { Box(modifier = Modifier.padding(8.dp)) { @@ -132,12 +108,7 @@ fun MapControlsOverlay( // Location tracking button MapButton( - icon = - if (isLocationTrackingEnabled) { - MeshtasticIcons.LocationDisabled - } else { - MeshtasticIcons.MyLocation - }, + icon = if (isLocationTrackingEnabled) MeshtasticIcons.LocationDisabled else MeshtasticIcons.MyLocation, contentDescription = stringResource(Res.string.toggle_my_position), onClick = onToggleLocationTracking, ) @@ -146,12 +117,16 @@ fun MapControlsOverlay( @Composable private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) { - val icon = if (isFollowing) MeshtasticIcons.MapCompass else MeshtasticIcons.MapCompass - + val iconTint = + when { + isFollowing -> MaterialTheme.colorScheme.primary + bearing == 0f -> MaterialTheme.colorScheme.StatusRed + else -> null + } MapButton( modifier = Modifier.rotate(-bearing), - icon = icon, - iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { bearing == 0f }, + icon = MeshtasticIcons.MapCompass, + iconTint = iconTint, contentDescription = stringResource(Res.string.orient_north), onClick = onClick, ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TracerouteOverlay.kt similarity index 65% rename from feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/TracerouteOverlay.kt index 7a9bb6627..97b5507ad 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TracerouteOverlay.kt @@ -14,15 +14,24 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.map.model +package org.meshtastic.core.model +/** + * Represents a traceroute result with forward and return routes as ordered lists of node nums. + * + * @property requestId The mesh packet request ID that initiated this traceroute. + * @property forwardRoute Ordered node nums along the path towards the destination. + * @property returnRoute Ordered node nums along the return path back to the originator. + */ data class TracerouteOverlay( val requestId: Int, val forwardRoute: List = emptyList(), val returnRoute: List = emptyList(), ) { + /** All unique node nums involved in either route direction. */ val relatedNodeNums: Set = (forwardRoute + returnRoute).toSet() + /** True if at least one route direction contains nodes. */ val hasRoutes: Boolean get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty() } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt new file mode 100644 index 000000000..252297754 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model.util + +/** Common geographic constants for coordinate conversions. */ +object GeoConstants { + /** Multiplier to convert protobuf integer coordinates (1e-7 degree units) to decimal degrees. */ + const val DEG_D = 1e-7 + + /** Multiplier to convert protobuf integer heading values (1e-5 degree units) to decimal degrees. */ + const val HEADING_DEG = 1e-5 + + /** Mean radius of the Earth in meters, for haversine calculations. */ + const val EARTH_RADIUS_METERS = 6_371_000.0 +} diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt index 0fdb4d48c..ed28ebccd 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt @@ -207,7 +207,7 @@ object DeepLinkRouter { private val nodeDetailSubRoutes: Map Route> = mapOf( "device-metrics" to { destNum -> NodeDetailRoute.DeviceMetrics(destNum) }, - "map" to { destNum -> NodeDetailRoute.NodeMap(destNum) }, + "map" to { destNum -> NodeDetailRoute.PositionLog(destNum) }, "position" to { destNum -> NodeDetailRoute.PositionLog(destNum) }, "environment" to { destNum -> NodeDetailRoute.EnvironmentMetrics(destNum) }, "signal" to { destNum -> NodeDetailRoute.SignalMetrics(destNum) }, diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index fc288a04c..7f43bf549 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -74,8 +74,6 @@ sealed interface NodesRoute : Route { sealed interface NodeDetailRoute : Route { @Serializable data class DeviceMetrics(val destNum: Int) : NodeDetailRoute - @Serializable data class NodeMap(val destNum: Int) : NodeDetailRoute - @Serializable data class PositionLog(val destNum: Int) : NodeDetailRoute @Serializable data class EnvironmentMetrics(val destNum: Int) : NodeDetailRoute diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt index a6ead2605..04bda7472 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt @@ -156,7 +156,7 @@ class DeepLinkRouterTest { listOf( NodesRoute.NodesGraph, NodesRoute.NodeDetailGraph(destNum = 5678), - NodeDetailRoute.NodeMap(destNum = 5678), + NodeDetailRoute.PositionLog(destNum = 5678), ), route("/nodes/5678/map"), ) diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt index e89879613..293c567fc 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt @@ -62,7 +62,6 @@ class NavigationConfigTest { NodesRoute.NodeDetail(), // NodeDetailRoute NodeDetailRoute.DeviceMetrics(destNum = 100), - NodeDetailRoute.NodeMap(destNum = 100), NodeDetailRoute.PositionLog(destNum = 100), NodeDetailRoute.EnvironmentMetrics(destNum = 100), NodeDetailRoute.SignalMetrics(destNum = 100), diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 7bb3a42dd..5d7eba25a 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -889,6 +889,12 @@ Type a message PAX Metrics PAX + PAX: %1$d + B:%1$d + W:%1$d + PAX: %1$s + BLE: %1$s + WiFi: %1$s No PAX metrics available. Wi-Fi Provisioning for mPWRD-OS Bluetooth Devices 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 new file mode 100644 index 000000000..5ac8eca5a --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import org.meshtastic.core.ui.component.PlaceholderScreen +import org.meshtastic.proto.Position + +/** + * Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions]. + * Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded + * inside another screen layout (e.g. the position-log adaptive layout). + * + * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. + */ +@Suppress("Wrapping") +val LocalNodeTrackMapProvider = + compositionLocalOf<@Composable (destNum: Int, positions: List, modifier: Modifier) -> Unit> { + { _, _, _ -> PlaceholderScreen("Position Track Map") } + } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt new file mode 100644 index 000000000..139992c54 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.ui.component.PlaceholderScreen +import org.meshtastic.proto.Position + +/** + * Provides an embeddable traceroute map composable that renders node markers and forward/return offset polylines for a + * traceroute result. Unlike [LocalMapViewProvider], this does **not** include a Scaffold, AppBar, waypoints, location + * tracking, custom tiles, or any main-map features — it is designed to be embedded inside `TracerouteMapScreen`'s + * scaffold. + * + * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. + * + * Parameters: + * - `tracerouteOverlay`: The overlay with forward/return route node nums. + * - `tracerouteNodePositions`: Map of node num to position snapshots for the route nodes. + * - `onMappableCountChanged`: Callback with (shown, total) node counts. + * - `modifier`: Compose modifier for the map. + */ +@Suppress("Wrapping") +val LocalTracerouteMapProvider = + compositionLocalOf< + @Composable ( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + onMappableCountChanged: (Int, Int) -> Unit, + modifier: Modifier, + ) -> Unit, + > { + { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } + } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt index 4561886e2..10d975f3d 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt @@ -22,23 +22,10 @@ import androidx.compose.ui.Modifier /** * Interface for providing a flavored MapView. This allows the map feature to be decoupled from specific map - * implementations (Google Maps vs osmdroid). + * implementations (Google Maps vs OSMDroid). Platform implementations create their own ViewModel via Koin. */ interface MapViewProvider { - @Composable - fun MapView( - modifier: Modifier, - // We use Any here to avoid circular dependency with feature:map - viewModel: Any, - navigateToNodeDetails: (Int) -> Unit, - focusedNodeNum: Int? = null, - // Using List to avoid dependency on proto.Position if needed - nodeTracks: List? = null, - tracerouteOverlay: Any? = null, - tracerouteNodePositions: Map = emptyMap(), - onTracerouteMappableCountChanged: (Int, Int) -> Unit = { _, _ -> }, - waypointId: Int? = null, - ) + @Composable fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int? = null) } val LocalMapViewProvider = compositionLocalOf { null } diff --git a/docs/agent-playbooks/kmp-source-set-bridging-playbook.md b/docs/agent-playbooks/kmp-source-set-bridging-playbook.md index e5e11da0b..62753020a 100644 --- a/docs/agent-playbooks/kmp-source-set-bridging-playbook.md +++ b/docs/agent-playbooks/kmp-source-set-bridging-playbook.md @@ -26,7 +26,9 @@ Examples in current code: - Platform/flavor UI implementations should be injected via `CompositionLocal` from app. Examples: -- Contract: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` +- Contract (main map): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` +- Contract (node tracks): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` +- Contract (traceroute): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` - Provider wiring: `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` ## 4) DI and module activation checks diff --git a/docs/agent-playbooks/task-playbooks.md b/docs/agent-playbooks/task-playbooks.md index 4a32623fb..25a856d9f 100644 --- a/docs/agent-playbooks/task-playbooks.md +++ b/docs/agent-playbooks/task-playbooks.md @@ -15,6 +15,8 @@ Key files for discovering established patterns: | Shared ViewModel | `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt` | | `CompositionLocal` platform injection | `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` | | Platform abstraction contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` | +| Node track map provider contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` | +| Traceroute map provider contract | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` | | Shared strings resource | `core/resources/src/commonMain/composeResources/values/strings.xml` | | Okio shared I/O | `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt` | | `stateInWhileSubscribed` | `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ViewModelExtensions.kt` | @@ -82,7 +84,9 @@ Reference examples: 4. Keep adapter types narrow and stable (interfaces, DTO-like params). Reference examples: -- Contract: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` +- Contract (main map): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` +- Contract (node tracks): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt` +- Contract (traceroute): `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` - Provider wiring: `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt` - Consumer side: `feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt` diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md index ce5becbb2..3d09d68f3 100644 --- a/docs/decisions/architecture-review-2026-03.md +++ b/docs/decisions/architecture-review-2026-03.md @@ -11,7 +11,7 @@ The codebase is **~98% structurally KMP** — 18/20 core modules and 8/8 feature Of the five structural gaps originally identified, four are resolved and one remains in progress: -1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 6 files: `MainActivity`, `MeshUtilApplication`, Nav shell, and DI config)* +1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 8 files: `MainActivity`, `MeshUtilApplication`, Nav shell, DI config, and shared map UI components)* 2. ~~**Radio transport layer is app-locked**~~ — ✅ Resolved: `RadioTransport` interface in `core:repository/commonMain`; shared `StreamFrameCodec` + `TcpTransport` in `core:network`. 3. ~~**`java.*` APIs leak into `commonMain`**~~ — ✅ Resolved: `Locale`, `ConcurrentHashMap`, `ReentrantLock` purged. 4. ~~**Zero feature-level `commonTest`**~~ — ✅ Resolved: 193 shared tests across all 8 features; `core:testing` module established. @@ -24,7 +24,7 @@ Of the five structural gaps originally identified, four are resolved and one rem | `core/*/commonMain` | 337 | 32,700 | Shared business/data logic | | `feature/*/commonMain` | 146 | 19,700 | Shared feature UI + ViewModels | | `feature/*/androidMain` | 62 | 14,700 | Platform UI (charts, previews, permissions) | -| `app/src/main` | 6 | ~300 | Android app shell (target achieved) | +| `app/src/main` | 8 | ~450 | Android app shell + shared map UI components | | `desktop/src` | 26 | 4,800 | Desktop app shell | | `core/*/androidMain` | 49 | 3,500 | Platform implementations | | `core/*/jvmMain` | 11 | ~500 | JVM actuals | @@ -38,7 +38,7 @@ Of the five structural gaps originally identified, four are resolved and one rem ### A1. `app` module is a God module -The `app` module should be a thin shell (~20 files): `MainActivity`, DI assembly, nav host. Originally it held **90 files / ~11K LOC**, now completely reduced to a **6-file shell**: +The `app` module should be a thin shell (~20 files): `MainActivity`, DI assembly, nav host, and shared flavor-agnostic UI. Originally it held **90 files / ~11K LOC**, now reduced to an **8-file shell** (6 original + 2 shared map UI components: `MapButton`, `MapControlsOverlay`): | Area | Files | LOC | Where it should live | |---|---:|---:|---| diff --git a/docs/kmp-status.md b/docs/kmp-status.md index c5362e479..95e4b6945 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -1,6 +1,6 @@ # KMP Migration Status -> Last updated: 2026-03-31 +> Last updated: 2026-04-10 Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/). @@ -49,7 +49,7 @@ Modules that share JVM-specific code between Android and desktop now standardize | `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; fully shared `contactsGraph`, `MessageScreen`, `ContactsScreen`, and `MessageListPaged` | | `feature:connections` | ✅ | ✅ Shared `ConnectionsScreen` with dynamic transport detection | | `feature:intro` | — | — | Screens remain in `androidMain`; shared ViewModel only | -| `feature:map` | — | Placeholder; shared `NodeMapViewModel` and `BaseMapViewModel` only | +| `feature:map` | — | Placeholder; shared `NodeMapViewModel`, `BaseMapViewModel`, and `TracerouteNodeSelection`. Map rendering decomposed into 3 `CompositionLocal` provider contracts (`MapViewProvider`, `NodeTrackMapProvider`, `TracerouteMapProvider`) with per-flavor implementations in `:app` | | `feature:firmware` | ✅ | ✅ Fully KMP: Unified OTA, native Secure DFU, USB/UF2, FirmwareRetriever | | `feature:wifi-provision` | ✅ | ✅ KMP WiFi provisioning via BLE (Nymea protocol); shared UI and ViewModel | | `feature:widget` | ❌ | — | Android-only (Glance appwidgets). Intentional. | @@ -144,6 +144,8 @@ Extracted to shared `commonMain` (no longer app-only): - `ChannelViewModel` → `feature:settings/commonMain` - `NodeMapViewModel` → `feature:map/commonMain` (Shared logic for node-specific maps) - `BaseMapViewModel` → `feature:map/commonMain` (Core contract for all maps) +- `TracerouteOverlay` → `core:model/commonMain` (Pure data class for traceroute route segments; extracted from `feature:map` for cross-module reuse) +- `GeoConstants` → `core:model/commonMain` (Centralized `DEG_D`, `HEADING_DEG`, `EARTH_RADIUS_METERS` constants; eliminates 7 duplicate private constants) Extracted to core KMP modules: - Android Services, WorkManager Workers, and BroadcastReceivers → `core:service/androidMain` @@ -151,7 +153,7 @@ Extracted to core KMP modules: - TCP radio connections, BLE radio connections (`BleRadioInterface`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations) Remaining to be extracted from `:app` or unified in `commonMain`: -- `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface) +- `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface. `MapViewProvider` interface simplified — track rendering and traceroute rendering extracted to dedicated provider contracts) ## Prerelease Dependencies diff --git a/docs/roadmap.md b/docs/roadmap.md index d7412c2cc..91d051f9f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -> Last updated: 2026-03-31 +> Last updated: 2026-04-10 Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). For the full gap analysis, see [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md). @@ -81,10 +81,10 @@ These items address structural gaps identified in the March 2026 architecture re 1. **Evaluate KMP-native testing tools** — ✅ **Done:** Fully evaluated and integrated `Mokkery`, `Turbine`, and `Kotest` across the KMP modules. `mockk` has been successfully replaced, enabling property-based and Flow testing in `commonTest` for iOS readiness. 2. **Desktop Map Integration** — Address the major Desktop feature gap by implementing a raster map view using [**MapComposeMP**](https://github.com/p-lr/MapComposeMP). - - Implement a `MapComposeProvider` for Desktop. + - Implement Desktop providers for the 3 decomposed map contracts: `MapViewProvider` (main map), `NodeTrackMapProvider` (per-node track overlay for `PositionLogScreen`), and `TracerouteMapProvider` (traceroute visualization). - Implement a **Web Mercator Projection** helper in `feature:map/commonMain` to translate GPS coordinates to the 2D image plane. - - Leverage the existing `BaseMapViewModel` contract. -3. **Unify `MapViewModel`** — Collapse the remaining Google and F-Droid specific `MapViewModel` classes in the `:app` module into a single `commonMain` implementation by isolating platform-specific settings (styles, tile sources) behind a repository interface. + - Leverage the existing `BaseMapViewModel` contract and `TracerouteNodeSelection` logic in `commonMain`. +3. **Unify `MapViewModel`** — Collapse the remaining Google and F-Droid specific `MapViewModel` classes in the `:app` module into a single `commonMain` implementation by isolating platform-specific settings (styles, tile sources) behind a repository interface. The `MapViewProvider` interface has been simplified (track/traceroute rendering extracted to dedicated providers), reducing the surface area of this unification. 4. **iOS CI gate** — ✅ **Done:** added `iosArm64()`/`iosSimulatorArm64()` to convention plugins and CI. `commonMain` successfully compiles on iOS. ## Medium-Term Priorities (60 days) diff --git a/feature/map/README.md b/feature/map/README.md index 3e38406a9..802f18913 100644 --- a/feature/map/README.md +++ b/feature/map/README.md @@ -1,24 +1,41 @@ # `:feature:map` ## Overview -The `:feature:map` module provides the mapping interface for the application. It supports multiple map providers and displays node positions, tracks, and waypoints. +The `:feature:map` module provides the mapping interface for the application. Map rendering is decomposed into three focused `CompositionLocal` provider contracts, each with per-flavor implementations in `:app`. -## Key Components +## Architecture -### 1. `MapScreen` -The main mapping interface. It integrates with flavor-specific map implementations (Google Maps for `google`, OpenStreetMap for `fdroid`). +### Provider Contracts (in `core:ui/commonMain`) -### 2. `BaseMapViewModel` -The base logic for managing map state, node markers, and camera positions. +| Contract | Purpose | Implementations | +|---|---|---| +| `MapViewProvider` | Main map (nodes, waypoints, controls) | `GoogleMapViewProvider`, `FdroidMapViewProvider` | +| `NodeTrackMapProvider` | Per-node GPS track overlay (embedded in `PositionLogScreen`) | Google: `NodeTrackMap` → `MapView(GoogleMapMode.NodeTrack)`, F-Droid: `NodeTrackMap` → `NodeTrackOsmMap` | +| `TracerouteMapProvider` | Traceroute route visualization | Google: `TracerouteMap` → `MapView(GoogleMapMode.Traceroute)`, F-Droid: `TracerouteMap` → `TracerouteOsmMap` | + +All providers are injected via `CompositionLocal` in `MainActivity.kt` and consumed by feature modules without direct dependency on Google Maps or osmdroid. + +### Shared ViewModels (in `commonMain`) + +- **`BaseMapViewModel`** — Core contract for all map state management, node markers, camera positions, and traceroute node selection logic (`TracerouteNodeSelection`, `tracerouteNodeSelection()`). +- **`NodeMapViewModel`** — Shared logic for per-node map views (track display, position history). + +### Key Data Types + +- **`TracerouteOverlay`** (`core:model/commonMain`) — Pure data class representing traceroute route segments. Extracted from `feature:map` for cross-module reuse. +- **`TracerouteNodeSelection`** (`feature:map/commonMain`) — Data class modeling node selection results during traceroute visualization. +- **`GeoConstants`** (`core:model/commonMain`) — Centralized geographic constants (`DEG_D`, `HEADING_DEG`, `EARTH_RADIUS_METERS`). ## Map Providers -- **Google Maps (`google` flavor)**: Uses Google Play Services Maps SDK. -- **OpenStreetMap (`fdroid` flavor)**: Uses `osmdroid` for a fully open-source mapping experience. +- **Google Maps (`google` flavor)**: Uses Google Play Services Maps SDK. Implementations in `app/src/google/kotlin/org/meshtastic/app/map/`. +- **OpenStreetMap (`fdroid` flavor)**: Uses `osmdroid` for a fully open-source experience. Implementations in `app/src/fdroid/kotlin/org/meshtastic/app/map/`. ## Features - **Live Node Tracking**: Real-time position updates for nodes on the mesh. - **Waypoints**: Create and share points of interest. +- **Per-Node Track Overlay**: Embedded map in `PositionLogScreen` showing a node's GPS track history. +- **Traceroute Visualization**: Dedicated map view showing route segments between mesh nodes. - **Offline Maps**: Support for pre-downloaded map tiles (via `osmdroid`). ## Module dependency graph diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index a018ca8e6..588ca198b 100644 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -57,7 +57,6 @@ fun MapScreen( ) { paddingValues -> LocalMapViewProvider.current?.MapView( modifier = Modifier.fillMaxSize().padding(paddingValues), - viewModel = viewModel, navigateToNodeDetails = navigateToNodeDetails, waypointId = waypointId, ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index e637b0d76..a1a31dbf4 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -31,6 +31,7 @@ import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController +import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository @@ -41,7 +42,6 @@ import org.meshtastic.core.resources.one_day import org.meshtastic.core.resources.one_hour import org.meshtastic.core.resources.two_days import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint @@ -194,16 +194,42 @@ open class BaseMapViewModel( ) } +/** + * Result of resolving a [TracerouteOverlay]'s node nums into displayable [Node] instances. + * + * @property overlayNodeNums All unique node nums referenced by the traceroute. + * @property nodesForMarkers Nodes to render as map markers (with snapshot positions when available). + * @property nodeLookup Node-num-keyed map for polyline coordinate resolution. + */ data class TracerouteNodeSelection( val overlayNodeNums: Set, val nodesForMarkers: List, val nodeLookup: Map, ) +/** Convenience extension that delegates to [tracerouteNodeSelection] using the VM's [getNodeOrFallback]. */ fun BaseMapViewModel.tracerouteNodeSelection( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map, nodes: List, +): TracerouteNodeSelection = tracerouteNodeSelection( + tracerouteOverlay = tracerouteOverlay, + tracerouteNodePositions = tracerouteNodePositions, + nodes = nodes, + getNodeOrFallback = ::getNodeOrFallback, +) + +/** + * Resolves traceroute overlay node nums into displayable [Node] instances. Snapshot positions (recorded at traceroute + * time) take priority over live positions from the node database. + * + * @param getNodeOrFallback Provides a [Node] for a given num, falling back to a stub if not in the DB. + */ +fun tracerouteNodeSelection( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + nodes: List, + getNodeOrFallback: (Int) -> Node, ): TracerouteNodeSelection { val overlayNodeNums = tracerouteOverlay?.relatedNodeNums ?: emptySet() val tracerouteSnapshotNodes = diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/LastHeardFilterTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/LastHeardFilterTest.kt new file mode 100644 index 000000000..052e85da9 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/LastHeardFilterTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map + +import kotlin.test.Test +import kotlin.test.assertEquals + +@Suppress("MagicNumber") +class LastHeardFilterTest { + + @Test + fun fromSeconds_knownValues() { + assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(0L)) + assertEquals(LastHeardFilter.OneHour, LastHeardFilter.fromSeconds(3600L)) + assertEquals(LastHeardFilter.EightHours, LastHeardFilter.fromSeconds(28800L)) + assertEquals(LastHeardFilter.OneDay, LastHeardFilter.fromSeconds(86400L)) + assertEquals(LastHeardFilter.TwoDays, LastHeardFilter.fromSeconds(172800L)) + } + + @Test + fun fromSeconds_unknownValue_defaultsToAny() { + assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(9999L)) + assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(-1L)) + assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(Long.MAX_VALUE)) + } + + @Test + fun seconds_matchExpectedValues() { + assertEquals(0L, LastHeardFilter.Any.seconds) + assertEquals(3600L, LastHeardFilter.OneHour.seconds) + assertEquals(28800L, LastHeardFilter.EightHours.seconds) + assertEquals(86400L, LastHeardFilter.OneDay.seconds) + assertEquals(172800L, LastHeardFilter.TwoDays.seconds) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TracerouteNodeSelectionTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TracerouteNodeSelectionTest.kt new file mode 100644 index 000000000..76ae25066 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TracerouteNodeSelectionTest.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map + +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.proto.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TracerouteNodeSelectionTest { + + private fun nodeWithPosition(num: Int, latI: Int = num * 100000, lonI: Int = num * 200000): Node = + Node(num = num, position = Position(latitude_i = latI, longitude_i = lonI)) + + private fun nodeWithoutPosition(num: Int): Node = Node(num = num, position = Position()) + + private val defaultGetNodeOrFallback: (Int) -> Node = { num -> Node(num = num) } + + // ---- Null overlay (no traceroute active) ---- + + @Test + fun nullOverlay_returnsAllNodesUnfiltered() { + val nodes = listOf(nodeWithPosition(1), nodeWithPosition(2), nodeWithPosition(3)) + val result = + tracerouteNodeSelection( + tracerouteOverlay = null, + tracerouteNodePositions = emptyMap(), + nodes = nodes, + getNodeOrFallback = defaultGetNodeOrFallback, + ) + + assertEquals(emptySet(), result.overlayNodeNums) + assertEquals(3, result.nodesForMarkers.size) + assertEquals(nodes.map { it.num }.toSet(), result.nodesForMarkers.map { it.num }.toSet()) + } + + @Test + fun nullOverlay_nodeLookupContainsOnlyNodesWithValidPositions() { + val nodes = listOf(nodeWithPosition(1), nodeWithoutPosition(2), nodeWithPosition(3)) + val result = + tracerouteNodeSelection( + tracerouteOverlay = null, + tracerouteNodePositions = emptyMap(), + nodes = nodes, + getNodeOrFallback = defaultGetNodeOrFallback, + ) + + // nodeLookup filters to validPosition nodes when no snapshot + assertEquals(setOf(1, 3), result.nodeLookup.keys) + } + + // ---- Overlay with snapshot positions ---- + + @Test + fun overlayWithSnapshot_usesSnapshotPositions() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(20, 10)) + val snapshotPositions = + mapOf( + 10 to Position(latitude_i = 400000000, longitude_i = -700000000), + 20 to Position(latitude_i = 410000000, longitude_i = -710000000), + ) + val liveNodes = + listOf( + nodeWithPosition(10, latI = 100000000, lonI = -100000000), + nodeWithPosition(20, latI = 200000000, lonI = -200000000), + nodeWithPosition(30), + ) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, + nodes = liveNodes, + getNodeOrFallback = { num -> liveNodes.find { it.num == num } ?: Node(num = num) }, + ) + + // Should use snapshot positions, not live ones + assertEquals(setOf(10, 20), result.overlayNodeNums) + assertEquals(2, result.nodesForMarkers.size) + assertEquals(400000000, result.nodesForMarkers.first { it.num == 10 }.position.latitude_i) + assertEquals(410000000, result.nodesForMarkers.first { it.num == 20 }.position.latitude_i) + } + + @Test + fun overlayWithSnapshot_nodeLookupUsesSnapshotNodes() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20)) + val snapshotPositions = + mapOf( + 10 to Position(latitude_i = 400000000, longitude_i = -700000000), + 20 to Position(latitude_i = 410000000, longitude_i = -710000000), + ) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, + nodes = emptyList(), + getNodeOrFallback = { num -> Node(num = num) }, + ) + + assertEquals(2, result.nodeLookup.size) + assertEquals(400000000, result.nodeLookup[10]?.position?.latitude_i) + } + + @Test + fun overlayWithSnapshot_filtersToOverlayNodes() { + // Snapshot has node 30 which is NOT in the overlay routes + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20)) + val snapshotPositions = + mapOf( + 10 to Position(latitude_i = 400000000, longitude_i = -700000000), + 20 to Position(latitude_i = 410000000, longitude_i = -710000000), + 30 to Position(latitude_i = 420000000, longitude_i = -720000000), + ) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, + nodes = emptyList(), + getNodeOrFallback = { num -> Node(num = num) }, + ) + + // nodesForMarkers should only contain nodes in the overlay (10, 20), not 30 + assertEquals(setOf(10, 20), result.nodesForMarkers.map { it.num }.toSet()) + // but nodeLookup has all snapshot nodes (for polyline drawing) + assertEquals(3, result.nodeLookup.size) + } + + // ---- Overlay without snapshot positions (live fallback) ---- + + @Test + fun overlayWithoutSnapshot_filtersLiveNodesToOverlayNums() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(30)) + val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20), nodeWithPosition(30), nodeWithPosition(40)) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = emptyMap(), + nodes = liveNodes, + getNodeOrFallback = defaultGetNodeOrFallback, + ) + + assertEquals(setOf(10, 20, 30), result.overlayNodeNums) + assertEquals(setOf(10, 20, 30), result.nodesForMarkers.map { it.num }.toSet()) + } + + @Test + fun overlayWithoutSnapshot_nodeLookupFiltersToValidPositions() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20)) + val liveNodes = listOf(nodeWithPosition(10), nodeWithoutPosition(20)) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = emptyMap(), + nodes = liveNodes, + getNodeOrFallback = defaultGetNodeOrFallback, + ) + + // nodeLookup only includes nodes with validPosition + assertEquals(setOf(10), result.nodeLookup.keys) + } + + // ---- Edge cases ---- + + @Test + fun emptyOverlayRoutes_yieldsEmptySelection() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = emptyList(), returnRoute = emptyList()) + val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20)) + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = emptyMap(), + nodes = liveNodes, + getNodeOrFallback = defaultGetNodeOrFallback, + ) + + assertTrue(result.overlayNodeNums.isEmpty()) + assertTrue(result.nodesForMarkers.isEmpty()) + } + + @Test + fun getNodeOrFallback_usedForSnapshotNodeLookup() { + val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10)) + val snapshotPositions = mapOf(10 to Position(latitude_i = 400000000, longitude_i = -700000000)) + var lookupCalledWith: Int? = null + val result = + tracerouteNodeSelection( + tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, + nodes = emptyList(), + getNodeOrFallback = { num -> + lookupCalledWith = num + Node(num = num) + }, + ) + + assertEquals(10, lookupCalledWith) + assertEquals(1, result.nodesForMarkers.size) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt index c19881280..2e0cbaed7 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/TracerouteOverlayTest.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.map.model +import org.meshtastic.core.model.TracerouteOverlay import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/feature/node/component/DeviceActions.kt b/feature/node/component/DeviceActions.kt deleted file mode 100644 index 7f652cca6..000000000 --- a/feature/node/component/DeviceActions.kt +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.feature.node.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import org.meshtastic.core.ui.icon.Delete -import org.meshtastic.core.ui.icon.Favorite -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Message -import org.meshtastic.core.ui.icon.NotFavorite -import org.meshtastic.core.ui.icon.QrCode2 -import org.meshtastic.core.ui.icon.VolumeMute -import org.meshtastic.core.ui.icon.VolumeOff -import org.meshtastic.core.ui.icon.VolumeUp -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconToggleButton -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.Node -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.actions -import org.meshtastic.core.resources.direct_message -import org.meshtastic.core.resources.favorite -import org.meshtastic.core.resources.ignore -import org.meshtastic.core.resources.mute_always -import org.meshtastic.core.resources.remove -import org.meshtastic.core.resources.share_contact -import org.meshtastic.core.resources.unmute -import org.meshtastic.core.ui.component.ListItem -import org.meshtastic.core.ui.component.SwitchListItem -import org.meshtastic.feature.node.model.NodeDetailAction -import org.meshtastic.feature.node.model.isEffectivelyUnmessageable - -@Composable -fun DeviceActions( - node: Node, - lastTracerouteTime: Long?, - lastRequestNeighborsTime: Long?, - onAction: (NodeDetailAction) -> Unit, - modifier: Modifier = Modifier, - isLocal: Boolean = false, -) { - var displayFavoriteDialog by remember { mutableStateOf(false) } - var displayIgnoreDialog by remember { mutableStateOf(false) } - var displayMuteDialog by remember { mutableStateOf(false) } - var displayRemoveDialog by remember { mutableStateOf(false) } - - NodeActionDialogs( - node = node, - displayFavoriteDialog = displayFavoriteDialog, - displayIgnoreDialog = displayIgnoreDialog, - displayMuteDialog = displayMuteDialog, - displayRemoveDialog = displayRemoveDialog, - onDismissMenuRequest = { - displayFavoriteDialog = false - displayIgnoreDialog = false - displayMuteDialog = false - displayRemoveDialog = false - }, - onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) }, - onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) }, - onConfirmMute = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(it))) }, - onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) }, - ) - - ElevatedCard( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), - shape = MaterialTheme.shapes.extraLarge, - ) { - DeviceActionsContent( - node = node, - isLocal = isLocal, - lastTracerouteTime = lastTracerouteTime, - lastRequestNeighborsTime = lastRequestNeighborsTime, - onAction = onAction, - onFavoriteClick = { displayFavoriteDialog = true }, - onIgnoreClick = { displayIgnoreDialog = true }, - onMuteClick = { displayMuteDialog = true }, - onRemoveClick = { displayRemoveDialog = true }, - ) - } -} - -@Composable -private fun DeviceActionsContent( - node: Node, - isLocal: Boolean, - lastTracerouteTime: Long?, - lastRequestNeighborsTime: Long?, - onAction: (NodeDetailAction) -> Unit, - onFavoriteClick: () -> Unit, - onIgnoreClick: () -> Unit, - onMuteClick: () -> Unit, - onRemoveClick: () -> Unit, -) { - Column(modifier = Modifier.padding(vertical = 12.dp)) { - Text( - text = stringResource(Res.string.actions), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - ) - - PrimaryActionsRow(node, isLocal, onAction, onFavoriteClick) - - if (!isLocal) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - - RemoteDeviceActions( - node = node, - lastTracerouteTime = lastTracerouteTime, - lastRequestNeighborsTime = lastRequestNeighborsTime, - onAction = onAction, - ) - } - - HorizontalDivider( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - - ManagementActions(node, onIgnoreClick, onMuteClick, onRemoveClick) - } -} - -@Composable -private fun PrimaryActionsRow( - node: Node, - isLocal: Boolean, - onAction: (NodeDetailAction) -> Unit, - onFavoriteClick: () -> Unit, -) { - Row( - modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!node.isEffectivelyUnmessageable && !isLocal) { - Button( - onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) }, - modifier = Modifier.weight(1f), - shape = MaterialTheme.shapes.large, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) { - Icon(MeshtasticIcons.Message, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.direct_message)) - } - } - - OutlinedButton( - onClick = { onAction(NodeDetailAction.ShareContact) }, - modifier = if (node.isEffectivelyUnmessageable || isLocal) Modifier.weight(1f) else Modifier, - shape = MaterialTheme.shapes.large, - ) { - Icon(MeshtasticIcons.QrCode2, contentDescription = null) - if (node.isEffectivelyUnmessageable || isLocal) { - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.share_contact)) - } - } - - IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) { - Icon( - imageVector = if (node.isFavorite) MeshtasticIcons.Favorite else MeshtasticIcons.NotFavorite, - contentDescription = stringResource(Res.string.favorite), - tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current, - ) - } - } -} - -@Composable -private fun ManagementActions( - node: Node, - onIgnoreClick: () -> Unit, - onMuteClick: () -> Unit, - onRemoveClick: () -> Unit, -) { - Column { - SwitchListItem( - text = stringResource(Res.string.ignore), - leadingIcon = - if (node.isIgnored) { - MeshtasticIcons.VolumeMute - } else { - MeshtasticIcons.VolumeUp - }, - checked = node.isIgnored, - onClick = onIgnoreClick, - ) - - SwitchListItem( - text = stringResource(if (node.isMuted) Res.string.unmute else Res.string.mute_always), - leadingIcon = if (node.isMuted) { - MeshtasticIcons.VolumeOff - } else { - MeshtasticIcons.VolumeUp - }, - checked = node.isMuted, - onClick = onMuteClick, - ) - - ListItem( - text = stringResource(Res.string.remove), - leadingIcon = MeshtasticIcons.Delete, - trailingIcon = null, - textColor = MaterialTheme.colorScheme.error, - leadingIconTint = MaterialTheme.colorScheme.error, - onClick = onRemoveClick, - ) - } -} diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index ec3cf5ea5..8a4c0d7d5 100644 --- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.flowOf import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.traceroute @@ -51,9 +52,8 @@ import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Route import org.meshtastic.core.ui.theme.TracerouteColors -import org.meshtastic.core.ui.util.LocalMapViewProvider import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider -import org.meshtastic.feature.map.model.TracerouteOverlay +import org.meshtastic.core.ui.util.LocalTracerouteMapProvider import org.meshtastic.proto.Position @Composable @@ -117,16 +117,14 @@ private fun TracerouteMapScaffold( }, ) { paddingValues -> Box(modifier = modifier.fillMaxSize().padding(paddingValues)) { - LocalMapViewProvider.current?.MapView( - modifier = Modifier, - viewModel = Unit, - navigateToNodeDetails = {}, - tracerouteOverlay = overlay, - tracerouteNodePositions = snapshotPositions, - onTracerouteMappableCountChanged = { shown: Int, total: Int -> + LocalTracerouteMapProvider.current( + overlay, + snapshotPositions, + { shown: Int, total: Int -> tracerouteNodesShown = shown tracerouteNodesTotal = total }, + Modifier.fillMaxSize(), ) Column( modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt index 26164c77b..a0a9290fe 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt @@ -58,18 +58,20 @@ import org.meshtastic.core.ui.icon.VolumeMute import org.meshtastic.core.ui.icon.VolumeOff import org.meshtastic.core.ui.icon.VolumeUp import org.meshtastic.feature.node.model.LogsType -import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.feature.node.model.isEffectivelyUnmessageable +import org.meshtastic.proto.Config @Composable fun DeviceActions( node: Node, + ourNode: Node?, lastTracerouteTime: Long?, lastRequestNeighborsTime: Long?, availableLogs: Set, onAction: (NodeDetailAction) -> Unit, - metricsState: MetricsState, + displayUnits: Config.DisplayConfig.DisplayUnits, + isFahrenheit: Boolean, modifier: Modifier = Modifier, isLocal: Boolean = false, ) { @@ -85,10 +87,12 @@ fun DeviceActions( TelemetricActionsSection( node = node, + ourNode = ourNode, availableLogs = availableLogs, lastTracerouteTime = lastTracerouteTime, lastRequestNeighborsTime = lastRequestNeighborsTime, - metricsState = metricsState, + displayUnits = displayUnits, + isFahrenheit = isFahrenheit, onAction = onAction, isLocal = isLocal, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt new file mode 100644 index 000000000..4d9287bec --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("TooManyFunctions", "MagicNumber") + +package org.meshtastic.feature.node.component + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.feature.node.model.LogsType +import org.meshtastic.proto.Config + +// --------------------------------------------------------------------------- +// Sample data for previews +// --------------------------------------------------------------------------- + +private val previewData = NodePreviewParameterProvider() + +// --------------------------------------------------------------------------- +// DeviceActions previews +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun DeviceActionsRemotePreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + DeviceActions( + node = node, + ourNode = previewData.mickeyMouse.copy(num = 9999), + lastTracerouteTime = null, + lastRequestNeighborsTime = null, + availableLogs = + setOf( + LogsType.DEVICE, + LogsType.POSITIONS, + LogsType.ENVIRONMENT, + LogsType.SIGNAL, + LogsType.TRACEROUTE, + ), + onAction = {}, + displayUnits = Config.DisplayConfig.DisplayUnits.METRIC, + isFahrenheit = false, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DeviceActionsLocalPreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + DeviceActions( + node = node, + ourNode = node, + lastTracerouteTime = null, + lastRequestNeighborsTime = null, + availableLogs = setOf(LogsType.DEVICE, LogsType.POSITIONS), + onAction = {}, + displayUnits = Config.DisplayConfig.DisplayUnits.METRIC, + isFahrenheit = false, + isLocal = true, + ) + } + } +} + +// --------------------------------------------------------------------------- +// TelemetricActionsSection previews +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun TelemetricActionsSectionPreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + TelemetricActionsSection( + node = node, + ourNode = previewData.mickeyMouse.copy(num = 9999), + availableLogs = + setOf( + LogsType.DEVICE, + LogsType.POSITIONS, + LogsType.ENVIRONMENT, + LogsType.SIGNAL, + LogsType.TRACEROUTE, + LogsType.NEIGHBOR_INFO, + ), + lastTracerouteTime = null, + lastRequestNeighborsTime = null, + displayUnits = Config.DisplayConfig.DisplayUnits.METRIC, + isFahrenheit = false, + onAction = {}, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun TelemetricActionsSectionEmptyPreview() { + val node = previewData.minnieMouse + AppTheme { + Surface { + TelemetricActionsSection( + node = node, + ourNode = previewData.mickeyMouse, + availableLogs = emptySet(), + lastTracerouteTime = null, + lastRequestNeighborsTime = null, + displayUnits = Config.DisplayConfig.DisplayUnits.IMPERIAL, + isFahrenheit = true, + onAction = {}, + ) + } + } +} + +// --------------------------------------------------------------------------- +// PositionInlineContent preview +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun PositionInlineContentPreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + PositionInlineContent( + node = node, + ourNode = previewData.mickeyMouse.copy(num = 9999), + displayUnits = Config.DisplayConfig.DisplayUnits.METRIC, + onAction = {}, + ) + } + } +} + +// --------------------------------------------------------------------------- +// NodeDetailsSection preview +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun NodeDetailsSectionPreview() { + val node = previewData.mickeyMouse + AppTheme { Surface { NodeDetailsSection(node = node) } } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index c687c620e..0ab017f7b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -16,10 +16,7 @@ */ package org.meshtastic.feature.node.component -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -28,9 +25,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.AssistChip -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -42,87 +36,48 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.exchange_position import org.meshtastic.core.resources.open_compass -import org.meshtastic.core.resources.position import org.meshtastic.core.ui.icon.Compass import org.meshtastic.core.ui.icon.Distance -import org.meshtastic.core.ui.icon.LocationOn import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.util.LocalInlineMapProvider -import org.meshtastic.feature.node.model.LogsType -import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.proto.Config -private const val EXCHANGE_BUTTON_WEIGHT = 1.1f -private const val COMPASS_BUTTON_WEIGHT = 0.9f private const val MAP_HEIGHT_DP = 200 /** - * Displays node position details, last update time, distance, and related actions like requesting position and - * accessing map/position logs. + * Inline position content shown beneath the Position row in the Telemetry section. Displays the inline map with + * distance badge, linked coordinates, and compass button. */ @Composable -fun PositionSection( +internal fun PositionInlineContent( node: Node, ourNode: Node?, - metricsState: MetricsState, - availableLogs: Set, + displayUnits: Config.DisplayConfig.DisplayUnits, onAction: (NodeDetailAction) -> Unit, - modifier: Modifier = Modifier, ) { - val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits) - val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0 - val isLocal = metricsState.isLocal + val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(displayUnits) - SectionCard(title = Res.string.position, modifier = modifier) { - Column(modifier = Modifier.padding(horizontal = 16.dp)) { - if (hasValidPosition) { - PositionMap(node, distance) - LinkedCoordinatesItem(node, metricsState.displayUnits) - Spacer(Modifier.height(8.dp)) - } - - PositionActionButtons( - node = node, - isLocal = isLocal, - hasValidPosition = hasValidPosition, - displayUnits = metricsState.displayUnits, - onAction = onAction, - ) - - if (availableLogs.contains(LogsType.NODE_MAP) || availableLogs.contains(LogsType.POSITIONS)) { - Spacer(Modifier.height(12.dp)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (availableLogs.contains(LogsType.NODE_MAP)) { - AssistChip( - onClick = { onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num))) }, - label = { Text(stringResource(LogsType.NODE_MAP.titleRes)) }, - leadingIcon = { Icon(vectorResource(LogsType.NODE_MAP.icon), null, Modifier.size(18.dp)) }, - ) - } - - if (availableLogs.contains(LogsType.POSITIONS)) { - AssistChip( - onClick = { - onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num))) - }, - label = { Text(stringResource(LogsType.POSITIONS.titleRes)) }, - leadingIcon = { Icon(vectorResource(LogsType.POSITIONS.icon), null, Modifier.size(18.dp)) }, - ) - } - } - } - } + PositionMap(node, distance) + LinkedCoordinatesItem(node, displayUnits) + Spacer(Modifier.height(8.dp)) + FilledTonalButton( + onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + ) { + Icon(MeshtasticIcons.Compass, null, Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(Res.string.open_compass), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } @@ -150,59 +105,3 @@ private fun PositionMap(node: Node, distance: String?) { } } } - -@Composable -private fun PositionActionButtons( - node: Node, - isLocal: Boolean, - hasValidPosition: Boolean, - displayUnits: Config.DisplayConfig.DisplayUnits, - onAction: (NodeDetailAction) -> Unit, -) { - if (isLocal && !hasValidPosition) return - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!isLocal) { - Button( - onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) }, - modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT), - shape = MaterialTheme.shapes.large, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) { - Icon(MeshtasticIcons.LocationOn, null, Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text( - text = stringResource(Res.string.exchange_position), - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Visible, - ) - } - } - - if (hasValidPosition) { - FilledTonalButton( - onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) }, - modifier = if (isLocal) Modifier.fillMaxWidth() else Modifier.weight(COMPASS_BUTTON_WEIGHT), - shape = MaterialTheme.shapes.large, - ) { - Icon(MeshtasticIcons.Compass, null, Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text( - text = stringResource(Res.string.open_compass), - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt index f3825817d..22588aebd 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt @@ -61,8 +61,8 @@ import org.meshtastic.core.resources.userinfo import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.feature.node.model.LogsType -import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.NodeDetailAction +import org.meshtastic.proto.Config private data class TelemetricFeature( val titleRes: StringResource, @@ -72,21 +72,32 @@ private data class TelemetricFeature( val isVisible: (Node) -> Boolean = { true }, val cooldownTimestamp: Long? = null, val cooldownDuration: Long = COOL_DOWN_TIME_MS, - val content: @Composable ((Node) -> Unit)? = null, + val content: @Composable ((Node, (NodeDetailAction) -> Unit) -> Unit)? = null, val hasContent: (Node) -> Boolean = { false }, ) @Composable internal fun TelemetricActionsSection( node: Node, + ourNode: Node?, availableLogs: Set, lastTracerouteTime: Long?, lastRequestNeighborsTime: Long?, - metricsState: MetricsState, + displayUnits: Config.DisplayConfig.DisplayUnits, + isFahrenheit: Boolean, onAction: (NodeDetailAction) -> Unit, isLocal: Boolean = false, ) { - val features = rememberTelemetricFeatures(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal) + val features = + rememberTelemetricFeatures( + node, + ourNode, + lastTracerouteTime, + lastRequestNeighborsTime, + displayUnits, + isFahrenheit, + isLocal, + ) SectionCard(title = Res.string.telemetry) { features @@ -111,83 +122,94 @@ internal fun TelemetricActionsSection( @Composable private fun rememberTelemetricFeatures( node: Node, + ourNode: Node?, lastTracerouteTime: Long?, lastRequestNeighborsTime: Long?, - metricsState: MetricsState, + displayUnits: Config.DisplayConfig.DisplayUnits, + isFahrenheit: Boolean, isLocal: Boolean, -): List = remember(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal) { - listOf( - TelemetricFeature( - titleRes = Res.string.userinfo, - icon = Res.drawable.ic_person, - requestAction = { NodeMenuAction.RequestUserInfo(it) }, - isVisible = { !isLocal }, - ), - TelemetricFeature( - titleRes = LogsType.TRACEROUTE.titleRes, - icon = LogsType.TRACEROUTE.icon, - requestAction = { NodeMenuAction.TraceRoute(it) }, - logsType = LogsType.TRACEROUTE, - cooldownTimestamp = lastTracerouteTime, - isVisible = { !isLocal }, - ), - TelemetricFeature( - titleRes = LogsType.NEIGHBOR_INFO.titleRes, - icon = LogsType.NEIGHBOR_INFO.icon, - requestAction = { NodeMenuAction.RequestNeighborInfo(it) }, - logsType = LogsType.NEIGHBOR_INFO, - isVisible = { it.capabilities.canRequestNeighborInfo }, - cooldownTimestamp = lastRequestNeighborsTime, - cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS, - ), - TelemetricFeature( - titleRes = LogsType.SIGNAL.titleRes, - icon = LogsType.SIGNAL.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) }, - logsType = LogsType.SIGNAL, - isVisible = { !isLocal }, - ), - TelemetricFeature( - titleRes = LogsType.DEVICE.titleRes, - icon = LogsType.DEVICE.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) }, - logsType = LogsType.DEVICE, - ), - TelemetricFeature( - titleRes = LogsType.ENVIRONMENT.titleRes, - icon = Res.drawable.ic_thermostat, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) }, - logsType = LogsType.ENVIRONMENT, - content = { EnvironmentMetrics(it, metricsState.displayUnits, metricsState.isFahrenheit) }, - hasContent = { it.hasEnvironmentMetrics }, - ), - TelemetricFeature( - titleRes = Res.string.request_air_quality_metrics, - icon = Res.drawable.ic_air, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) }, - ), - TelemetricFeature( - titleRes = LogsType.POWER.titleRes, - icon = LogsType.POWER.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) }, - logsType = LogsType.POWER, - content = { PowerMetrics(it) }, - hasContent = { it.hasPowerMetrics }, - ), - TelemetricFeature( - titleRes = LogsType.HOST.titleRes, - icon = LogsType.HOST.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) }, - logsType = LogsType.HOST, - ), - TelemetricFeature( - titleRes = LogsType.PAX.titleRes, - icon = LogsType.PAX.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) }, - logsType = LogsType.PAX, - ), - ) -} +): List = + remember(node, ourNode, lastTracerouteTime, lastRequestNeighborsTime, displayUnits, isFahrenheit, isLocal) { + listOf( + TelemetricFeature( + titleRes = Res.string.userinfo, + icon = Res.drawable.ic_person, + requestAction = { NodeMenuAction.RequestUserInfo(it) }, + isVisible = { !isLocal }, + ), + TelemetricFeature( + titleRes = LogsType.POSITIONS.titleRes, + icon = LogsType.POSITIONS.icon, + requestAction = if (isLocal) null else { n -> NodeMenuAction.RequestPosition(n) }, + logsType = LogsType.POSITIONS, + content = { node, action -> PositionInlineContent(node, ourNode, displayUnits, action) }, + hasContent = { it.latitude != 0.0 || it.longitude != 0.0 }, + ), + TelemetricFeature( + titleRes = LogsType.TRACEROUTE.titleRes, + icon = LogsType.TRACEROUTE.icon, + requestAction = { NodeMenuAction.TraceRoute(it) }, + logsType = LogsType.TRACEROUTE, + cooldownTimestamp = lastTracerouteTime, + isVisible = { !isLocal }, + ), + TelemetricFeature( + titleRes = LogsType.NEIGHBOR_INFO.titleRes, + icon = LogsType.NEIGHBOR_INFO.icon, + requestAction = { NodeMenuAction.RequestNeighborInfo(it) }, + logsType = LogsType.NEIGHBOR_INFO, + isVisible = { it.capabilities.canRequestNeighborInfo }, + cooldownTimestamp = lastRequestNeighborsTime, + cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS, + ), + TelemetricFeature( + titleRes = LogsType.SIGNAL.titleRes, + icon = LogsType.SIGNAL.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) }, + logsType = LogsType.SIGNAL, + isVisible = { !isLocal }, + ), + TelemetricFeature( + titleRes = LogsType.DEVICE.titleRes, + icon = LogsType.DEVICE.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) }, + logsType = LogsType.DEVICE, + ), + TelemetricFeature( + titleRes = LogsType.ENVIRONMENT.titleRes, + icon = Res.drawable.ic_thermostat, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) }, + logsType = LogsType.ENVIRONMENT, + content = { node, _ -> EnvironmentMetrics(node, displayUnits, isFahrenheit) }, + hasContent = { it.hasEnvironmentMetrics }, + ), + TelemetricFeature( + titleRes = Res.string.request_air_quality_metrics, + icon = Res.drawable.ic_air, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) }, + ), + TelemetricFeature( + titleRes = LogsType.POWER.titleRes, + icon = LogsType.POWER.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) }, + logsType = LogsType.POWER, + content = { node, _ -> PowerMetrics(node) }, + hasContent = { it.hasPowerMetrics }, + ), + TelemetricFeature( + titleRes = LogsType.HOST.titleRes, + icon = LogsType.HOST.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) }, + logsType = LogsType.HOST, + ), + TelemetricFeature( + titleRes = LogsType.PAX.titleRes, + icon = LogsType.PAX.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) }, + logsType = LogsType.PAX, + ), + ) + } @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") @@ -273,7 +295,7 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean, if (showContent) { Column(modifier = Modifier.padding(start = 56.dp, end = 20.dp, bottom = 12.dp)) { - feature.content.invoke(node) + feature.content.invoke(node, onAction) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt index e0d8fe1d1..03367debf 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt @@ -41,7 +41,6 @@ import org.meshtastic.feature.node.component.DeviceActions import org.meshtastic.feature.node.component.DeviceDetailsSection import org.meshtastic.feature.node.component.NodeDetailsSection import org.meshtastic.feature.node.component.NotesSection -import org.meshtastic.feature.node.component.PositionSection import org.meshtastic.feature.node.model.NodeDetailAction /** @@ -81,8 +80,8 @@ fun NodeDetailContent( } /** - * Scrollable list of node detail sections: identity, device actions, position, hardware details, notes, and - * administration. + * Scrollable list of node detail sections: identity, device actions (including telemetry and position), hardware + * details, notes, and administration. */ @Composable fun NodeDetailList( @@ -105,15 +104,16 @@ fun NodeDetailList( item { DeviceActions( node = node, + ourNode = ourNode, lastTracerouteTime = uiState.lastTracerouteTime, lastRequestNeighborsTime = uiState.lastRequestNeighborsTime, availableLogs = uiState.availableLogs, onAction = onAction, - metricsState = uiState.metricsState, + displayUnits = uiState.metricsState.displayUnits, + isFahrenheit = uiState.metricsState.isFahrenheit, isLocal = uiState.metricsState.isLocal, ) } - item { PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction) } if (uiState.metricsState.deviceHardware != null) { item { DeviceDetailsSection(uiState.metricsState) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt new file mode 100644 index 000000000..caa68b106 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("TooManyFunctions", "MagicNumber") + +package org.meshtastic.feature.node.detail + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.feature.node.model.LogsType +import org.meshtastic.feature.node.model.MetricsState + +// --------------------------------------------------------------------------- +// Sample data for previews +// --------------------------------------------------------------------------- + +private val previewData = NodePreviewParameterProvider() + +// --------------------------------------------------------------------------- +// NodeDetailContent previews +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun NodeDetailContentRemotePreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + NodeDetailContent( + uiState = + NodeDetailUiState( + node = node, + ourNode = previewData.mickeyMouse.copy(num = 9999), + metricsState = MetricsState(isLocal = false, isManaged = false), + availableLogs = + setOf( + LogsType.DEVICE, + LogsType.POSITIONS, + LogsType.ENVIRONMENT, + LogsType.SIGNAL, + LogsType.TRACEROUTE, + ), + ), + onAction = {}, + onFirmwareSelect = {}, + onSaveNotes = { _, _ -> }, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun NodeDetailContentLocalPreview() { + val node = previewData.mickeyMouse + AppTheme { + Surface { + NodeDetailContent( + uiState = + NodeDetailUiState( + node = node, + ourNode = node, + metricsState = MetricsState(isLocal = true, isManaged = false), + availableLogs = setOf(LogsType.DEVICE, LogsType.POSITIONS), + ), + onAction = {}, + onFirmwareSelect = {}, + onSaveNotes = { _, _ -> }, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun NodeDetailContentLoadingPreview() { + AppTheme { + Surface { + NodeDetailContent( + uiState = NodeDetailUiState(), + onAction = {}, + onFirmwareSelect = {}, + onSaveNotes = { _, _ -> }, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun NodeDetailContentMinimalPreview() { + val node = previewData.minnieMouse + AppTheme { + Surface { + NodeDetailContent( + uiState = + NodeDetailUiState( + node = node, + ourNode = previewData.mickeyMouse, + metricsState = MetricsState(isLocal = false, isManaged = true), + availableLogs = emptySet(), + ), + onAction = {}, + onFirmwareSelect = {}, + onSaveNotes = { _, _ -> }, + ) + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt index 661010deb..a7b33f6a7 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt @@ -186,7 +186,6 @@ constructor( val availableLogs = buildSet { if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE) if (metricsState.hasPositionLogs()) { - add(LogsType.NODE_MAP) add(LogsType.POSITIONS) } if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT) 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 cf3fd8d3a..0b9f40044 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 @@ -19,9 +19,9 @@ package org.meshtastic.feature.node.metrics import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -31,16 +31,13 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp @@ -65,16 +62,12 @@ import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.avg import org.meshtastic.core.resources.collapse_chart import org.meshtastic.core.resources.expand_chart import org.meshtastic.core.resources.info import org.meshtastic.core.resources.logs -import org.meshtastic.core.resources.max -import org.meshtastic.core.resources.min import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.icon.BarChart import org.meshtastic.core.ui.icon.Info @@ -137,6 +130,46 @@ fun GenericMetricChart( ) } +/** + * Common scaffold for all metric chart composables. Provides: + * - A [Column] container with the supplied [modifier] + * - An empty-data guard (returns early when [isEmpty] is true) + * - A remembered [CartesianChartModelProducer] passed to [content] + * - A trailing [Legend] strip + * + * @param isEmpty Whether the chart data is empty — when true, nothing is rendered. + * @param legendData Legend items shown below the chart. + * @param key Optional key for the [CartesianChartModelProducer] (e.g. a selected channel). Pass a different value to + * recreate the producer. + * @param hiddenSet Indices of hidden legend items (toggleable legend). + * @param onToggle Callback when a legend item is toggled; when null, a read-only legend is rendered. + * @param content Builder lambda receiving the [CartesianChartModelProducer] and a standard `Modifier.weight(1f)` + * suitable for the chart area. + */ +@Composable +fun MetricChartScaffold( + isEmpty: Boolean, + legendData: List, + modifier: Modifier = Modifier, + key: Any? = Unit, + hiddenSet: Set = emptySet(), + onToggle: ((Int) -> Unit)? = null, + content: @Composable ColumnScope.(CartesianChartModelProducer, Modifier) -> Unit, +) { + Column(modifier = modifier) { + if (isEmpty) return@Column + val modelProducer = remember(key) { CartesianChartModelProducer() } + val chartModifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp) + content(modelProducer, chartModifier) + Legend( + legendData = legendData, + modifier = Modifier.padding(top = 0.dp), + hiddenSet = hiddenSet, + onToggle = onToggle, + ) + } +} + /** * An adaptive layout for metric screens. Uses a split Row for wide screens (tablets/landscape) and a stacked Column for * narrow screens (phones). When [isChartExpanded] is true, the card list is hidden and the chart fills the available @@ -164,7 +197,7 @@ fun AdaptiveMetricLayout( if (isChartExpanded) { Modifier.fillMaxWidth().weight(1f) } else { - Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f) + Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.45f) }, ) AnimatedVisibility(visible = !isChartExpanded, enter = expandVertically(), exit = shrinkVertically()) { @@ -175,40 +208,6 @@ fun AdaptiveMetricLayout( } } -/** - * Displays a compact row of min/max/avg statistics for a metric. Intended to be placed between the chart controls and - * the chart itself. - */ -@Composable -fun MetricSummaryRow(values: List, label: String = "", modifier: Modifier = Modifier) { - if (values.isEmpty()) return - val minVal = values.min() - val maxVal = values.max() - val avgVal = values.average().toFloat() - - Row( - modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - SummaryChip(label = stringResource(Res.string.min), value = formatString("%.1f %s", minVal, label)) - SummaryChip(label = stringResource(Res.string.avg), value = formatString("%.1f %s", avgVal, label)) - SummaryChip(label = stringResource(Res.string.max), value = formatString("%.1f %s", maxVal, label)) - } -} - -@Composable -private fun SummaryChip(label: String, value: String) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text(text = value, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface) - } -} - /** * A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and chart-to-list * synchronisation. diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt index 81709c6fd..c1cf0e04e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt @@ -29,10 +29,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.decoration.Decoration import com.patrykandpatrick.vico.compose.cartesian.decoration.HorizontalLine import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker import com.patrykandpatrick.vico.compose.cartesian.marker.DefaultCartesianMarker import com.patrykandpatrick.vico.compose.cartesian.marker.LineCartesianLayerMarkerTarget @@ -249,10 +252,13 @@ object ChartStyling { if (target is LineCartesianLayerMarkerTarget) { target.points.forEachIndexed { pointIndex, point -> if (pointIndex > 0) append(", ") - // Force alpha to 1f so text is readable even if the line is transparent/subtle - val color = point.color.copy(alpha = .8f) - val text = format(point.entry.y, color) - withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) { append(text) } + // Pass the opaque color to the format lambda so callers can match without alpha gymnastics. + // Apply 0.8 alpha only on the rendered text for readability. + val opaqueColor = point.color.copy(alpha = 1f) + val text = format(point.entry.y, opaqueColor) + withStyle(SpanStyle(color = opaqueColor.copy(alpha = .8f), fontWeight = FontWeight.Bold)) { + append(text) + } } } } @@ -267,3 +273,25 @@ object ChartStyling { fun rememberAxisLabel(color: Color = MaterialTheme.colorScheme.onSurfaceVariant): TextComponent = rememberTextComponent(style = TextStyle(color = color, fontSize = 10.sp, fontWeight = FontWeight.Medium)) } + +/** + * Creates a [LineCartesianLayer] only when [hasData] is true, returning null otherwise. + * + * Extracts the repeated `if (data.isNotEmpty()) rememberLineCartesianLayer(...) else null` pattern used in every metric + * chart composable. + */ +@Composable +fun rememberConditionalLayer( + hasData: Boolean, + lineProvider: LineCartesianLayer.LineProvider, + verticalAxisPosition: Axis.Position.Vertical, + rangeProvider: CartesianLayerRangeProvider? = null, +): LineCartesianLayer? = if (hasData) { + rememberLineCartesianLayer( + lineProvider = lineProvider, + verticalAxisPosition = verticalAxisPosition, + rangeProvider = rangeProvider ?: CartesianLayerRangeProvider.auto(), + ) +} else { + null +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt index 628c7e2e8..bb6efdff6 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.patrykandpatrick.vico.compose.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis @@ -56,6 +57,7 @@ import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.model.util.TimeConstants +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.close import org.meshtastic.core.resources.info @@ -63,29 +65,13 @@ import org.meshtastic.core.resources.rssi import org.meshtastic.core.resources.snr import org.meshtastic.core.ui.icon.Info import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.theme.AppTheme import kotlin.time.Duration.Companion.days object CommonCharts { - const val MS_PER_SEC = 1000L const val MAX_PERCENT_VALUE = 100f const val SCROLL_BIAS = 0.5f - /** Gets the Material 3 primary color with optional opacity adjustment. */ - @Composable - fun getMaterial3PrimaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.primary.copy(alpha = alpha) - - /** Gets the Material 3 secondary color with optional opacity adjustment. */ - @Composable - fun getMaterial3SecondaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha) - - /** Gets the Material 3 tertiary color with optional opacity adjustment. */ - @Composable - fun getMaterial3TertiaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.tertiary.copy(alpha = alpha) - - /** Gets the Material 3 error color with optional opacity adjustment. */ - @Composable - fun getMaterial3ErrorColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.error.copy(alpha = alpha) - /** * A dynamic [CartesianValueFormatter] that adjusts the time format based on the total data span * ([CartesianRanges.xLength]). @@ -118,8 +104,6 @@ object CommonCharts { } } - fun formatDateTime(timestampMillis: Long): String = DateFormatter.formatDateTime(timestampMillis) - /** * Shared bottom time axis used by all metric chart screens. * @@ -142,7 +126,7 @@ data class LegendData( val nameRes: StringResource, val color: Color, val isLine: Boolean = false, - val environmentMetric: Environment? = null, + val metricKey: Any? = null, ) data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color) @@ -163,9 +147,9 @@ fun Legend( onToggle: ((Int) -> Unit)? = null, ) { FlowRow( - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.Center, - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), ) { legendData.forEachIndexed { index, data -> val isVisible = index !in hiddenSet @@ -173,7 +157,7 @@ fun Legend( FilterChip( selected = isVisible, onClick = { onToggle(index) }, - label = { Text(stringResource(data.nameRes)) }, + label = { Text(text = stringResource(data.nameRes), style = MaterialTheme.typography.labelSmall) }, leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) }, modifier = Modifier.padding(horizontal = 2.dp), ) @@ -262,7 +246,8 @@ fun MetricIndicator(color: Color, modifier: Modifier = Modifier) { Box(modifier = modifier.size(8.dp).clip(CircleShape).background(color)) } -@Suppress("UnusedPrivateMember") // Compose preview +@PreviewLightDark +@Suppress("unused") // Compose preview @Composable private fun LegendPreview() { val data = @@ -270,10 +255,12 @@ private fun LegendPreview() { LegendData(nameRes = Res.string.rssi, color = Color.Red, isLine = true), LegendData(nameRes = Res.string.snr, color = Color.Green, isLine = true), ) - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - // Read-only legend - Legend(legendData = data) - // Toggleable legend - Legend(legendData = data, hiddenSet = setOf(1), onToggle = {}) + AppTheme { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + // Read-only legend + Legend(legendData = data) + // Toggleable legend + Legend(legendData = data, hiddenSet = setOf(1), onToggle = {}) + } } } 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 73b415035..a3fef636f 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 @@ -15,11 +15,10 @@ * along with this program. If not, see . */ @file:Suppress("MagicNumber") +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -32,9 +31,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -49,21 +45,22 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.air_util_definition @@ -84,7 +81,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Cyan import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.Green import org.meshtastic.core.ui.theme.GraphColors.Purple -import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.proto.Telemetry private enum class Device(val color: Color) { @@ -106,20 +102,10 @@ private enum class Device(val color: Color) { private val LEGEND_DATA = listOf( - LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true, environmentMetric = null), - LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true, environmentMetric = null), - LegendData( - nameRes = Res.string.channel_utilization, - color = Device.CH_UTIL.color, - isLine = true, - environmentMetric = null, - ), - LegendData( - nameRes = Res.string.air_utilization, - color = Device.AIR_UTIL.color, - isLine = true, - environmentMetric = null, - ), + LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true), + LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true), + LegendData(nameRes = Res.string.channel_utilization, color = Device.CH_UTIL.color, isLine = true), + LegendData(nameRes = Res.string.air_utilization, color = Device.AIR_UTIL.color, isLine = true), ) @Suppress("LongMethod") @@ -188,10 +174,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { onTimeFrameSelected = viewModel::setTimeFrame, modifier = Modifier.padding(horizontal = 16.dp), ) - if (hasBattery) { - val batteryValues = remember(data) { data.mapNotNull { it.device_metrics?.battery_level?.toFloat() } } - MetricSummaryRow(values = batteryValues, label = "%") - } }, chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> DeviceMetricsChart( @@ -219,7 +201,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun DeviceMetricsChart( modifier: Modifier = Modifier, telemetries: List, @@ -228,10 +209,10 @@ private fun DeviceMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - Column(modifier = modifier) { - if (telemetries.isEmpty()) return@Column - - val modelProducer = remember { CartesianChartModelProducer() } + MetricChartScaffold(isEmpty = telemetries.isEmpty(), legendData = legendData, modifier = modifier) { + modelProducer, + chartModifier, + -> val batteryColor = Device.BATTERY.color val voltageColor = Device.VOLTAGE.color val chUtilColor = Device.CH_UTIL.color @@ -247,7 +228,7 @@ private fun DeviceMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - when (color.copy(alpha = 1f)) { + when (color) { batteryColor -> formatString(percentValueTemplate, batteryLabel, value) voltageColor -> formatString(voltageValueTemplate, voltageLabel, value) chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value) @@ -322,28 +303,20 @@ private fun DeviceMetricsChart( } val leftLayer = - if (leftLayerSeriesStyles.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles), - verticalAxisPosition = Axis.Position.Vertical.Start, - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0), - ) - } else { - null - } + rememberConditionalLayer( + hasData = leftLayerSeriesStyles.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0), + ) val rightLayer = - if (voltageData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createGradientLine(lineColor = voltageColor), - ), - verticalAxisPosition = Axis.Position.Vertical.End, - ) - } else { - null - } + rememberConditionalLayer( + hasData = voltageData.isNotEmpty(), + lineProvider = + LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(lineColor = voltageColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + ) val layers = remember(leftLayer, rightLayer) { listOfNotNull(leftLayer, rightLayer) } @@ -356,7 +329,7 @@ private fun DeviceMetricsChart( GenericMetricChart( modelProducer = modelProducer, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), + modifier = chartModifier, layers = layers, startAxis = if (leftLayer != null) { @@ -384,14 +357,12 @@ private fun DeviceMetricsChart( vicoScrollState = vicoScrollState, ) } - - Legend(legendData = legendData, modifier = Modifier.padding(top = 0.dp)) } } -@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data +@PreviewLightDark +@Suppress("detekt:MagicNumber") // Compose preview with fake data @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun DeviceMetricsChartPreview() { val now = nowSeconds.toInt() val telemetries = @@ -422,7 +393,6 @@ private fun DeviceMetricsChartPreview() { @Composable @Suppress("LongMethod") -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { val deviceMetrics = telemetry.device_metrics val time = telemetry.time.toLong() * MS_PER_SEC @@ -431,101 +401,75 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick val uptimeLabel = stringResource(Res.string.uptime) val percentValueTemplate = stringResource(Res.string.device_metrics_percent_value) val labelValueTemplate = stringResource(Res.string.device_metrics_label_value) - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Surface(color = Color.Transparent) { - SelectionContainer { - Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { - /* Time, Battery, and Voltage */ - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = CommonCharts.formatDateTime(time), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.Bold, - ) + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + /* Time, Battery, and Voltage */ + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = DateFormatter.formatDateTime(time), + style = MaterialTheme.typography.titleMediumEmphasized, + fontWeight = FontWeight.Bold, + ) - Row(verticalAlignment = Alignment.CenterVertically) { - if (deviceMetrics?.battery_level != null) { - MetricIndicator(Device.BATTERY.color) - Spacer(Modifier.width(4.dp)) - } - if (deviceMetrics?.voltage != null) { - MetricIndicator(Device.VOLTAGE.color) - Spacer(Modifier.width(8.dp)) - } - MaterialBatteryInfo( - level = deviceMetrics?.battery_level ?: 0, - voltage = deviceMetrics?.voltage ?: 0f, - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + if (deviceMetrics?.battery_level != null) { + MetricIndicator(Device.BATTERY.color) + Spacer(Modifier.width(4.dp)) } + if (deviceMetrics?.voltage != null) { + MetricIndicator(Device.VOLTAGE.color) + Spacer(Modifier.width(8.dp)) + } + MaterialBatteryInfo( + level = deviceMetrics?.battery_level ?: 0, + voltage = deviceMetrics?.voltage ?: 0f, + ) + } + } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - /* Channel Utilization and Air Utilization Tx */ - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (deviceMetrics?.channel_utilization != null) { - MetricIndicator(Device.CH_UTIL.color) - Spacer(Modifier.width(4.dp)) - Text( - text = - formatString( - percentValueTemplate, - channelUtilizationLabel, - deviceMetrics.channel_utilization ?: 0f, - ), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - Spacer(Modifier.width(12.dp)) - } - if (deviceMetrics?.air_util_tx != null) { - MetricIndicator(Device.AIR_UTIL.color) - Spacer(Modifier.width(4.dp)) - Text( - text = - formatString( - percentValueTemplate, - airUtilizationLabel, - deviceMetrics.air_util_tx ?: 0f, - ), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - } - } - Text( + /* Channel Utilization and Air Utilization Tx */ + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (deviceMetrics?.channel_utilization != null) { + MetricValueRow( + color = Device.CH_UTIL.color, text = formatString( - labelValueTemplate, - uptimeLabel, - formatUptime(deviceMetrics?.uptime_seconds ?: 0), + percentValueTemplate, + channelUtilizationLabel, + deviceMetrics.channel_utilization ?: 0f, + ), + ) + Spacer(Modifier.width(12.dp)) + } + if (deviceMetrics?.air_util_tx != null) { + MetricValueRow( + color = Device.AIR_UTIL.color, + text = + formatString( + percentValueTemplate, + airUtilizationLabel, + deviceMetrics.air_util_tx ?: 0f, ), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, ) } } + Text( + text = + formatString(labelValueTemplate, uptimeLabel, formatUptime(deviceMetrics?.uptime_seconds ?: 0)), + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) } } } } -@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data +@PreviewLightDark +@Suppress("detekt:MagicNumber") // Compose preview with fake data @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun DeviceMetricsCardPreview() { val now = nowSeconds.toInt() val telemetry = @@ -543,9 +487,9 @@ private fun DeviceMetricsCardPreview() { AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) } } -@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data +@PreviewLightDark +@Suppress("detekt:MagicNumber") // Compose preview with fake data @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun DeviceMetricsScreenPreview() { val now = nowSeconds.toInt() val telemetries = diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt index cd8a4ab3f..c0164dd80 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt @@ -57,52 +57,42 @@ private val LEGEND_DATA_1 = nameRes = Res.string.temperature, color = Environment.TEMPERATURE.color, isLine = true, - environmentMetric = Environment.TEMPERATURE, + metricKey = Environment.TEMPERATURE, ), LegendData( nameRes = Res.string.humidity, color = Environment.HUMIDITY.color, isLine = true, - environmentMetric = Environment.HUMIDITY, + metricKey = Environment.HUMIDITY, ), ) private val LEGEND_DATA_2 = listOf( - LegendData( - nameRes = Res.string.iaq, - color = Environment.IAQ.color, - isLine = true, - environmentMetric = Environment.IAQ, - ), + LegendData(nameRes = Res.string.iaq, color = Environment.IAQ.color, isLine = true, metricKey = Environment.IAQ), LegendData( nameRes = Res.string.baro_pressure, color = Environment.BAROMETRIC_PRESSURE.color, isLine = true, - environmentMetric = Environment.BAROMETRIC_PRESSURE, - ), - LegendData( - nameRes = Res.string.lux, - color = Environment.LUX.color, - isLine = true, - environmentMetric = Environment.LUX, + metricKey = Environment.BAROMETRIC_PRESSURE, ), + LegendData(nameRes = Res.string.lux, color = Environment.LUX.color, isLine = true, metricKey = Environment.LUX), LegendData( nameRes = Res.string.uv_lux, color = Environment.UV_LUX.color, isLine = true, - environmentMetric = Environment.UV_LUX, + metricKey = Environment.UV_LUX, ), LegendData( nameRes = Res.string.wind_speed, color = Environment.WIND_SPEED.color, isLine = true, - environmentMetric = Environment.WIND_SPEED, + metricKey = Environment.WIND_SPEED, ), LegendData( nameRes = Res.string.radiation, color = Environment.RADIATION.color, isLine = true, - environmentMetric = Environment.RADIATION, + metricKey = Environment.RADIATION, ), ) @@ -112,13 +102,13 @@ private val LEGEND_DATA_3 = nameRes = Res.string.soil_temperature, color = Environment.SOIL_TEMPERATURE.color, isLine = true, - environmentMetric = Environment.SOIL_TEMPERATURE, + metricKey = Environment.SOIL_TEMPERATURE, ), LegendData( nameRes = Res.string.soil_moisture, color = Environment.SOIL_MOISTURE.color, isLine = true, - environmentMetric = Environment.SOIL_MOISTURE, + metricKey = Environment.SOIL_MOISTURE, ), ) @@ -143,14 +133,14 @@ fun EnvironmentMetricsChart( val allLegendData = (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter { - graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] + graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0] } // Legend toggle state: tracks indices into allLegendData that are hidden var hiddenIndices by remember { mutableStateOf(emptySet()) } val hiddenMetrics = remember(hiddenIndices, allLegendData) { - hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.environmentMetric }.toSet() + hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet() } val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) } @@ -216,7 +206,7 @@ fun EnvironmentMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - val label = colorToLabel[color.copy(alpha = 1f)] ?: "" + val label = colorToLabel[color] ?: "" formatString("%s: %.1f", label, value) }, ) 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 ee830a08e..2b47fd5e1 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 @@ -15,11 +15,10 @@ * along with this program. If not, see . */ @file:Suppress("TooManyFunctions") +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,26 +30,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.current import org.meshtastic.core.resources.env_metrics_log @@ -73,7 +70,7 @@ import org.meshtastic.core.resources.wind_lull import org.meshtastic.core.resources.wind_speed import org.meshtastic.core.ui.component.IaqDisplayMode import org.meshtastic.core.ui.component.IndoorAirQuality -import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC +import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.proto.Telemetry @Composable @@ -100,14 +97,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un onTimeFrameSelected = viewModel::setTimeFrame, modifier = Modifier.padding(horizontal = 16.dp), ) - val tempValues = - remember(filteredTelemetries) { - filteredTelemetries.mapNotNull { it.environment_metrics?.temperature?.takeIf { t -> !t.isNaN() } } - } - if (tempValues.isNotEmpty()) { - val unit = if (state.isFahrenheit) "°F" else "°C" - MetricSummaryRow(values = tempValues, label = unit) - } }, chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> EnvironmentMetricsChart( @@ -135,7 +124,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun TemperatureDisplay( envMetrics: org.meshtastic.proto.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean, @@ -157,7 +145,6 @@ private fun TemperatureDisplay( } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val hasHumidity = envMetrics.relative_humidity?.let { !it.isNaN() } == true val hasPressure = envMetrics.barometric_pressure?.let { !it.isNaN() && it > 0 } == true @@ -198,7 +185,6 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun SoilMetricsDisplay( envMetrics: org.meshtastic.proto.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean, @@ -251,7 +237,6 @@ private fun SoilMetricsDisplay( } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val hasLux = envMetrics.lux != null && !envMetrics.lux!!.isNaN() val hasUvLux = envMetrics.uv_lux != null && !envMetrics.uv_lux!!.isNaN() @@ -287,7 +272,6 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage!!.isNaN() val hasCurrent = envMetrics.current != null && !envMetrics.current!!.isNaN() @@ -315,7 +299,6 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val iaqValue = envMetrics.iaq val gasResistance = envMetrics.gas_resistance @@ -351,7 +334,6 @@ private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { envMetrics.radiation?.let { radiation -> if (!radiation.isNaN() && radiation > 0f) { @@ -371,7 +353,6 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val hasSpeed = envMetrics.wind_speed != null && !envMetrics.wind_speed!!.isNaN() val hasGust = envMetrics.wind_gust != null && !envMetrics.wind_gust!!.isNaN() @@ -386,7 +367,6 @@ private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -414,7 +394,6 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, hasGust: Boolean, hasLull: Boolean) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (hasGust) { @@ -435,7 +414,6 @@ private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val has1h = envMetrics.rainfall_1h != null && !envMetrics.rainfall_1h!!.isNaN() val has24h = envMetrics.rainfall_24h != null && !envMetrics.rainfall_24h!!.isNaN() @@ -462,34 +440,18 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun EnvironmentMetricsCard( telemetry: Telemetry, environmentDisplayFahrenheit: Boolean, isSelected: Boolean, onClick: () -> Unit, ) { - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Surface(color = Color.Transparent) { - SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } - } + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { + EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) { val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics() val time = telemetry.time.toLong() * MS_PER_SEC @@ -497,7 +459,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa /* Time and Temperature */ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( - text = CommonCharts.formatDateTime(time), + text = DateFormatter.formatDateTime(time), style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.Bold, ) @@ -521,9 +483,9 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa } } -@Suppress("MagicNumber", "UnusedPrivateMember") // Compose preview with fake data +@PreviewLightDark +@Suppress("MagicNumber") // Compose preview with fake data @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PreviewEnvironmentMetricsContent() { val fakeEnvMetrics = org.meshtastic.proto.EnvironmentMetrics( @@ -547,7 +509,5 @@ private fun PreviewEnvironmentMetricsContent() { rainfall_24h = 12.3f, ) val fakeTelemetry = Telemetry(time = nowSeconds.toInt(), environment_metrics = fakeEnvMetrics) - MaterialTheme { - Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) } - } + AppTheme { Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt index f04121bca..d4f362ca4 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt @@ -18,22 +18,17 @@ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import org.meshtastic.core.common.util.formatString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.free_memory @@ -104,11 +99,10 @@ internal fun HostMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - Column(modifier = modifier) { - if (data.isEmpty()) return@Column - - val modelProducer = remember { CartesianChartModelProducer() } - + MetricChartScaffold(isEmpty = data.isEmpty(), legendData = HOST_METRICS_LEGEND_DATA, modifier = modifier) { + modelProducer, + chartModifier, + -> val load1Data = remember(data) { data.filter { it.host_metrics?.load1 != null && it.host_metrics!!.load1 > 0 } } val load5Data = remember(data) { data.filter { it.host_metrics?.load5 != null && it.host_metrics!!.load5 > 0 } } val load15Data = @@ -157,7 +151,7 @@ internal fun HostMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - when (color.copy(alpha = 1f)) { + when (color) { load1Color -> formatString("L1: %.2f", value) load5Color -> formatString("L5: %.2f", value) load15Color -> formatString("L15: %.2f", value) @@ -167,39 +161,33 @@ internal fun HostMetricsChart( ) val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty() + val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null + val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null + val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null + val loadStyles = listOfNotNull(load1Style, load5Style, load15Style) val loadLayer = - if (hasLoad) { - val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null - val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null - val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null - val styles = listOfNotNull(load1Style, load5Style, load15Style) - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(styles), - verticalAxisPosition = Axis.Position.Vertical.Start, - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), - ) - } else { - null - } + rememberConditionalLayer( + hasData = hasLoad, + lineProvider = LineCartesianLayer.LineProvider.series(loadStyles), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + ) val memLayer = - if (memData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(memColor)), - verticalAxisPosition = Axis.Position.Vertical.End, - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), - ) - } else { - null - } + rememberConditionalLayer( + hasData = memData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(memColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + ) val layers = remember(loadLayer, memLayer) { listOfNotNull(loadLayer, memLayer) } if (layers.isNotEmpty()) { GenericMetricChart( modelProducer = modelProducer, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), + modifier = chartModifier, layers = layers, startAxis = if (hasLoad) { @@ -226,7 +214,5 @@ internal fun HostMetricsChart( vicoScrollState = vicoScrollState, ) } - - Legend(legendData = HOST_METRICS_LEGEND_DATA, modifier = Modifier.padding(top = 0.dp)) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt index 4a928b98a..653293835 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricLogComponents.kt @@ -14,9 +14,13 @@ * 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.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -27,6 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenuItem @@ -38,6 +43,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -49,7 +55,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons /** Shared metric log/list UI components used by TracerouteLog, NeighborInfoLog, HostMetricsLog, and PositionLog. */ @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) { Card( modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp), @@ -99,3 +104,45 @@ fun DeleteItem(onClick: () -> Unit) { }, ) } + +/** + * A selectable [Card] for metric log items. Provides consistent selection styling (primary border + primaryContainer + * background) and text selection support across all metric screens. + */ +@Composable +fun SelectableMetricCard( + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Card( + modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + SelectionContainer { content() } + } +} + +/** A compact row displaying a colored [MetricIndicator] dot/line followed by a text value. */ +@Composable +fun MetricValueRow(color: Color, text: String, modifier: Modifier = Modifier) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + MetricIndicator(color) + Spacer(Modifier.width(4.dp)) + Text( + text = text, + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + ) + } +} 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 93bfb5212..51ef4ef8c 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 @@ -47,7 +47,9 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.Node import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.evaluateTracerouteMapAvailability +import org.meshtastic.core.model.util.GeoConstants import org.meshtastic.core.model.util.UnitConversions import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.MeshLogRepository @@ -61,7 +63,6 @@ import org.meshtastic.core.resources.view_on_map import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.core.ui.util.toMessageRes import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.detail.NodeRequestActions import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.feature.node.model.MetricsState @@ -333,12 +334,12 @@ open class MetricsViewModel( .toLocalDateTime(TimeZone.currentSystemDefault()) val rxDateTime = "\"${localDateTime.date}\",\"${localDateTime.time}\"" - val latitude = (position.latitude_i ?: 0) * 1e-7 - val longitude = (position.longitude_i ?: 0) * 1e-7 + val latitude = (position.latitude_i ?: 0) * GeoConstants.DEG_D + val longitude = (position.longitude_i ?: 0) * GeoConstants.DEG_D val altitude = position.altitude val satsInView = position.sats_in_view val speed = position.ground_speed - val heading = formatString("%.2f", (position.ground_track ?: 0) * 1e-5) + val heading = formatString("%.2f", (position.ground_track ?: 0) * GeoConstants.HEADING_DEG) sink.writeUtf8( "$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n", diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index 595167a7e..cad2b63b1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -14,10 +14,10 @@ * 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.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -28,8 +28,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -46,7 +44,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer @@ -57,12 +54,19 @@ import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.ble_devices import org.meshtastic.core.resources.no_pax_metrics_logs import org.meshtastic.core.resources.pax +import org.meshtastic.core.resources.pax_ble_format +import org.meshtastic.core.resources.pax_ble_marker import org.meshtastic.core.resources.pax_metrics_log +import org.meshtastic.core.resources.pax_total_format +import org.meshtastic.core.resources.pax_total_marker +import org.meshtastic.core.resources.pax_wifi_format +import org.meshtastic.core.resources.pax_wifi_marker import org.meshtastic.core.resources.uptime import org.meshtastic.core.resources.wifi_devices import org.meshtastic.core.ui.component.IconInfo @@ -80,14 +84,13 @@ private enum class PaxSeries(val color: Color, val legendRes: StringResource) { private val LEGEND_DATA = listOf( - LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null), - LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null), - LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null), + LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color), + LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color), + LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color), ) @Suppress("LongMethod") @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PaxMetricsChart( modifier: Modifier = Modifier, totalSeries: List>, @@ -97,10 +100,10 @@ private fun PaxMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - Column(modifier = modifier) { - if (totalSeries.isEmpty()) return@Column - - val modelProducer = remember { CartesianChartModelProducer() } + MetricChartScaffold(isEmpty = totalSeries.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) { + modelProducer, + chartModifier, + -> val paxColor = PaxSeries.PAX.color val bleColor = PaxSeries.BLE.color val wifiColor = PaxSeries.WIFI.color @@ -116,22 +119,26 @@ private fun PaxMetricsChart( } val axisLabel = ChartStyling.rememberAxisLabel() + val bleMarkerTemplate = stringResource(Res.string.pax_ble_marker) + val wifiMarkerTemplate = stringResource(Res.string.pax_wifi_marker) + val paxMarkerTemplate = stringResource(Res.string.pax_total_marker) val marker = ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - when (color.copy(alpha = 1f)) { - bleColor -> formatString("BLE: %.0f", value) - wifiColor -> formatString("WiFi: %.0f", value) - paxColor -> formatString("PAX: %.0f", value) - else -> formatString("%.0f", value) + val formatted = formatString("%.0f", value) + when (color) { + bleColor -> bleMarkerTemplate.replace("%1\$s", formatted) + wifiColor -> wifiMarkerTemplate.replace("%1\$s", formatted) + paxColor -> paxMarkerTemplate.replace("%1\$s", formatted) + else -> formatted } }, ) GenericMetricChart( modelProducer = modelProducer, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), + modifier = chartModifier, layers = listOf( rememberLineCartesianLayer( @@ -151,8 +158,6 @@ private fun PaxMetricsChart( onPointSelected = onPointSelected, vicoScrollState = vicoScrollState, ) - - Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 4.dp)) } } @@ -169,7 +174,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni remember(paxMetrics) { paxMetrics .map { - val t = (it.first.received_date / CommonCharts.MS_PER_SEC).toInt() + val t = (it.first.received_date / MS_PER_SEC).toInt() Triple(t, it.second.ble, it.second.wifi) } .sortedBy { it.first } @@ -184,7 +189,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni titleRes = Res.string.pax_metrics_log, nodeName = state.node?.user?.long_name ?: "", data = paxMetrics, - timeProvider = { (it.first.received_date / CommonCharts.MS_PER_SEC).toDouble() }, + timeProvider = { (it.first.received_date / MS_PER_SEC).toDouble() }, onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) }, controlPart = { TimeFrameSelector( @@ -224,8 +229,8 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni PaxMetricsItem( log = log, pax = pax, - isSelected = (log.received_date / CommonCharts.MS_PER_SEC).toDouble() == selectedX, - onClick = { onCardClick((log.received_date / CommonCharts.MS_PER_SEC).toDouble()) }, + isSelected = (log.received_date / MS_PER_SEC).toDouble() == selectedX, + onClick = { onCardClick((log.received_date / MS_PER_SEC).toDouble()) }, ) } } @@ -250,21 +255,8 @@ fun PaxcountInfo( } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClick: () -> Unit) { - Card( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { Text( text = DateFormatter.formatDateTime(log.received_date), @@ -278,17 +270,20 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClic verticalAlignment = Alignment.CenterVertically, ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { - MetricIndicator(PaxSeries.PAX.color) - Spacer(Modifier.width(4.dp)) - Text(text = "PAX: ${pax.ble + pax.wifi}", style = MaterialTheme.typography.bodyLarge) + MetricValueRow( + color = PaxSeries.PAX.color, + text = stringResource(Res.string.pax_total_format, pax.ble + pax.wifi), + ) Spacer(Modifier.width(8.dp)) - MetricIndicator(PaxSeries.BLE.color) - Spacer(Modifier.width(4.dp)) - Text(text = "B:${pax.ble}", style = MaterialTheme.typography.bodyLarge) + MetricValueRow( + color = PaxSeries.BLE.color, + text = stringResource(Res.string.pax_ble_format, pax.ble), + ) Spacer(Modifier.width(8.dp)) - MetricIndicator(PaxSeries.WIFI.color) - Spacer(Modifier.width(4.dp)) - Text(text = "W:${pax.wifi}", style = MaterialTheme.typography.bodyLarge) + MetricValueRow( + color = PaxSeries.WIFI.color, + text = stringResource(Res.string.pax_wifi_format, pax.wifi), + ) } Text( 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 2a79f2fb1..62ab7a0d4 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 @@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.model.util.GeoConstants.DEG_D +import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG import org.meshtastic.core.model.util.metersIn import org.meshtastic.core.model.util.toString import org.meshtastic.core.resources.Res @@ -79,9 +81,6 @@ fun PositionLogHeader(compactWidth: Boolean) { } } -const val DEG_D = 1e-7 -const val HEADING_DEG = 1e-5 - @Composable fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) { Row( 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 a67d5d7dd..cb7d147d2 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 @@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -44,12 +45,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.clear +import org.meshtastic.core.resources.collapse_chart +import org.meshtastic.core.resources.expand_chart +import org.meshtastic.core.resources.logs +import org.meshtastic.core.resources.position_log import org.meshtastic.core.resources.save import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.icon.BarChart import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.List import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.icon.Save +import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider @Composable private fun ActionButtons( @@ -92,16 +100,32 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { org.meshtastic.core.ui.util.rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri) } var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) } + var isMapExpanded by remember { mutableStateOf(false) } + + val trackMap = LocalNodeTrackMapProvider.current + val destNum = state.node?.num ?: 0 Scaffold( topBar = { MainAppBar( title = state.node?.user?.long_name ?: "", + subtitle = + stringResource(Res.string.position_log) + + " (${state.positionLogs.size} ${stringResource(Res.string.logs)})", ourNode = null, showNodeChip = false, canNavigateUp = true, onNavigateUp = onNavigateUp, actions = { + IconButton(onClick = { isMapExpanded = !isMapExpanded }) { + Icon( + imageVector = if (isMapExpanded) MeshtasticIcons.List else MeshtasticIcons.BarChart, + contentDescription = + stringResource( + if (isMapExpanded) Res.string.collapse_chart else Res.string.expand_chart, + ), + ) + } if (!state.isLocal) { IconButton(onClick = { viewModel.requestPosition() }) { Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) @@ -112,30 +136,38 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { ) }, ) { innerPadding -> - BoxWithConstraints(modifier = Modifier.padding(innerPadding)) { - val compactWidth = maxWidth < 600.dp - Column { - val textStyle = - if (compactWidth) { - MaterialTheme.typography.bodySmall - } else { - LocalTextStyle.current - } - CompositionLocalProvider(LocalTextStyle provides textStyle) { - PositionLogHeader(compactWidth) - PositionList(compactWidth, state.positionLogs, state.displayUnits) - } + Column(modifier = Modifier.padding(innerPadding)) { + AdaptiveMetricLayout( + isChartExpanded = isMapExpanded, + chartPart = { modifier -> trackMap(destNum, state.positionLogs, modifier) }, + listPart = { modifier -> + BoxWithConstraints(modifier = modifier) { + val compactWidth = maxWidth < 600.dp + Column { + val textStyle = + if (compactWidth) { + MaterialTheme.typography.bodySmall + } else { + LocalTextStyle.current + } + CompositionLocalProvider(LocalTextStyle provides textStyle) { + PositionLogHeader(compactWidth) + PositionList(compactWidth, state.positionLogs, state.displayUnits) + } - ActionButtons( - clearButtonEnabled = clearButtonEnabled, - onClear = { - clearButtonEnabled = false - viewModel.clearPosition() - }, - saveButtonEnabled = state.hasPositionLogs(), - onSave = { exportPositionLauncher("position.csv", "text/csv") }, - ) - } + ActionButtons( + clearButtonEnabled = clearButtonEnabled, + onClear = { + clearButtonEnabled = false + viewModel.clearPosition() + }, + saveButtonEnabled = state.hasPositionLogs(), + onSave = { exportPositionLauncher("position.csv", "text/csv") }, + ) + } + } + }, + ) } } } 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 234ba269a..ebfae8407 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 @@ -15,11 +15,10 @@ * along with this program. If not, see . */ @file:Suppress("MagicNumber") +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -29,17 +28,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -47,7 +41,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle @@ -57,14 +50,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_1 import org.meshtastic.core.resources.channel_2 @@ -79,7 +72,6 @@ import org.meshtastic.core.resources.power_metrics_log import org.meshtastic.core.resources.voltage import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue -import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.proto.Telemetry private enum class PowerMetric(val color: Color) { @@ -100,18 +92,8 @@ private enum class PowerChannel(val strRes: StringResource) { private val LEGEND_DATA = listOf( - LegendData( - nameRes = Res.string.current, - color = PowerMetric.CURRENT.color, - isLine = true, - environmentMetric = null, - ), - LegendData( - nameRes = Res.string.voltage, - color = PowerMetric.VOLTAGE.color, - isLine = true, - environmentMetric = null, - ), + LegendData(nameRes = Res.string.current, color = PowerMetric.CURRENT.color, isLine = true), + LegendData(nameRes = Res.string.voltage, color = PowerMetric.VOLTAGE.color, isLine = true), ) @Suppress("LongMethod") @@ -187,7 +169,6 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { @Suppress("LongMethod") @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PowerMetricsChart( modifier: Modifier = Modifier, telemetries: List, @@ -196,17 +177,19 @@ private fun PowerMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - Column(modifier = modifier) { - if (telemetries.isEmpty()) return@Column - - val modelProducer = remember { CartesianChartModelProducer() } + MetricChartScaffold( + isEmpty = telemetries.isEmpty(), + legendData = LEGEND_DATA, + modifier = modifier, + key = selectedChannel, + ) { modelProducer, chartModifier -> val currentColor = PowerMetric.CURRENT.color val voltageColor = PowerMetric.VOLTAGE.color val marker = ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - when (color.copy(alpha = 1f)) { + when (color) { currentColor -> formatString("Current: %.0f mA", value) voltageColor -> formatString("Voltage: %.1f V", value) else -> formatString("%.1f", value) @@ -223,7 +206,7 @@ private fun PowerMetricsChart( telemetries.filter { !retrieveVoltage(selectedChannel, it).isNaN() } } - LaunchedEffect(currentData, voltageData) { + LaunchedEffect(selectedChannel, currentData, voltageData) { modelProducer.runTransaction { if (currentData.isNotEmpty()) { lineSeries { @@ -245,32 +228,25 @@ private fun PowerMetricsChart( } val currentLayer = - if (currentData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(currentColor)), - verticalAxisPosition = Axis.Position.Vertical.Start, - ) - } else { - null - } + rememberConditionalLayer( + hasData = currentData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(currentColor)), + verticalAxisPosition = Axis.Position.Vertical.Start, + ) val voltageLayer = - if (voltageData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = - LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(voltageColor)), - verticalAxisPosition = Axis.Position.Vertical.End, - ) - } else { - null - } + rememberConditionalLayer( + hasData = voltageData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(voltageColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + ) val layers = remember(currentLayer, voltageLayer) { listOfNotNull(currentLayer, voltageLayer) } if (layers.isNotEmpty()) { GenericMetricChart( modelProducer = modelProducer, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), + modifier = chartModifier, layers = layers, startAxis = if (currentData.isNotEmpty()) { @@ -297,50 +273,31 @@ private fun PowerMetricsChart( vicoScrollState = vicoScrollState, ) } - - Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp)) } } @Composable @Suppress("CyclomaticComplexMethod", "LongMethod") -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { val time = telemetry.time.toLong() * MS_PER_SEC - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Surface { - SelectionContainer { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(12.dp)) { - /* Time */ - Row { - Text( - text = CommonCharts.formatDateTime(time), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.Bold, - ) - } + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + /* Time */ + Row { + Text( + text = DateFormatter.formatDateTime(time), + style = MaterialTheme.typography.titleMediumEmphasized, + fontWeight = FontWeight.Bold, + ) + } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - val pm = telemetry.power_metrics - if (pm != null) { - PowerChannelsRow1(pm) - PowerChannelsExtraRows(pm) - } - } + val pm = telemetry.power_metrics + if (pm != null) { + PowerChannelsRow1(pm) + PowerChannelsExtraRows(pm) } } } @@ -348,7 +305,6 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PowerChannelsRow1(pm: org.meshtastic.proto.PowerMetrics) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { if (pm.ch1_current != null || pm.ch1_voltage != null) { @@ -365,7 +321,6 @@ private fun PowerChannelsRow1(pm: org.meshtastic.proto.PowerMetrics) { @Composable @Suppress("CyclomaticComplexMethod") -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PowerChannelsExtraRows(pm: org.meshtastic.proto.PowerMetrics) { val hasCh456 = hasChannelData(pm.ch4_voltage, pm.ch4_current) || @@ -403,7 +358,6 @@ private fun PowerChannelsExtraRows(pm: org.meshtastic.proto.PowerMetrics) { private fun hasChannelData(voltage: Float?, current: Float?): Boolean = voltage != null || current != null @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current: Float) { Column { Text( @@ -411,30 +365,13 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current style = TextStyle(fontWeight = FontWeight.Bold), fontSize = MaterialTheme.typography.labelLarge.fontSize, ) - Row(verticalAlignment = Alignment.CenterVertically) { - MetricIndicator(PowerMetric.VOLTAGE.color) - Spacer(Modifier.width(4.dp)) - Text( - text = formatString("%.2fV", voltage), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - MetricIndicator(PowerMetric.CURRENT.color) - Spacer(Modifier.width(4.dp)) - Text( - text = formatString("%.1fmA", current), - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) - } + MetricValueRow(color = PowerMetric.VOLTAGE.color, text = formatString("%.2fV", voltage)) + MetricValueRow(color = PowerMetric.CURRENT.color, text = formatString("%.1fmA", current)) } } /** Retrieves the appropriate voltage depending on `channelSelected`. */ @Suppress("CyclomaticComplexMethod") -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) { PowerChannel.ONE -> telemetry.power_metrics?.ch1_voltage ?: Float.NaN PowerChannel.TWO -> telemetry.power_metrics?.ch2_voltage ?: Float.NaN @@ -448,7 +385,6 @@ private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry) /** Retrieves the appropriate current depending on `channelSelected`. */ @Suppress("CyclomaticComplexMethod") -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) { PowerChannel.ONE -> telemetry.power_metrics?.ch1_current ?: Float.NaN PowerChannel.TWO -> telemetry.power_metrics?.ch2_current ?: Float.NaN 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 4105eb749..376b55289 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 @@ -14,10 +14,10 @@ * 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.BorderStroke -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,12 +31,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -51,12 +47,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer +import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.rssi import org.meshtastic.core.resources.rssi_definition @@ -66,7 +62,6 @@ import org.meshtastic.core.resources.snr_definition import org.meshtastic.core.ui.component.LoraSignalIndicator import org.meshtastic.core.ui.theme.GraphColors.Blue import org.meshtastic.core.ui.theme.GraphColors.Green -import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.proto.MeshPacket private enum class SignalMetric(val color: Color) { @@ -76,8 +71,8 @@ private enum class SignalMetric(val color: Color) { private val LEGEND_DATA = listOf( - LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color, environmentMetric = null), - LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color, environmentMetric = null), + LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color), + LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color), ) @Suppress("LongMethod") @@ -134,7 +129,6 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun SignalMetricsChart( modifier: Modifier = Modifier, meshPackets: List, @@ -142,10 +136,10 @@ private fun SignalMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - Column(modifier = modifier) { - if (meshPackets.isEmpty()) return@Column - - val modelProducer = remember { CartesianChartModelProducer() } + MetricChartScaffold(isEmpty = meshPackets.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) { + modelProducer, + chartModifier, + -> val rssiColor = SignalMetric.RSSI.color val snrColor = SignalMetric.SNR.color @@ -168,7 +162,7 @@ private fun SignalMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - if (color.copy(alpha = 1f) == rssiColor) { + if (color == rssiColor) { formatString("RSSI: %.0f dBm", value) } else { formatString("SNR: %.1f dB", value) @@ -177,31 +171,25 @@ private fun SignalMetricsChart( ) val rssiLayer = - if (rssiData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)), - verticalAxisPosition = Axis.Position.Vertical.Start, - ) - } else { - null - } + rememberConditionalLayer( + hasData = rssiData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)), + verticalAxisPosition = Axis.Position.Vertical.Start, + ) val snrLayer = - if (snrData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(snrColor)), - verticalAxisPosition = Axis.Position.Vertical.End, - ) - } else { - null - } + rememberConditionalLayer( + hasData = snrData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(snrColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + ) val layers = remember(rssiLayer, snrLayer) { listOfNotNull(rssiLayer, snrLayer) } if (layers.isNotEmpty()) { GenericMetricChart( modelProducer = modelProducer, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), + modifier = chartModifier, layers = layers, startAxis = if (rssiData.isNotEmpty()) { @@ -228,70 +216,47 @@ private fun SignalMetricsChart( vicoScrollState = vicoScrollState, ) } - - Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp)) } } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) { val time = meshPacket.rx_time.toLong() * MS_PER_SEC - Card( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = - CardDefaults.cardColors( - containerColor = - if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Surface(color = Color.Transparent) { - SelectionContainer { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - /* Data */ - Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) { - Column(modifier = Modifier.padding(12.dp)) { - /* Time */ - Row(horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = CommonCharts.formatDateTime(time), - style = MaterialTheme.typography.titleMediumEmphasized, - fontWeight = FontWeight.Bold, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - /* SNR and RSSI */ - Row(verticalAlignment = Alignment.CenterVertically) { - MetricIndicator(SignalMetric.RSSI.color) - Spacer(Modifier.width(4.dp)) - Text( - text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()), - style = MaterialTheme.typography.labelLarge, - ) - Spacer(Modifier.width(12.dp)) - MetricIndicator(SignalMetric.SNR.color) - Spacer(Modifier.width(4.dp)) - Text( - text = formatString("%.1f dB", meshPacket.rx_snr), - style = MaterialTheme.typography.labelLarge, - ) - } - } + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + /* Data */ + Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) { + Column(modifier = Modifier.padding(12.dp)) { + /* Time */ + Row(horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = DateFormatter.formatDateTime(time), + style = MaterialTheme.typography.titleMediumEmphasized, + fontWeight = FontWeight.Bold, + ) } - /* Signal Indicator */ - Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) { - LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi) + Spacer(modifier = Modifier.height(8.dp)) + + /* SNR and RSSI */ + Row(verticalAlignment = Alignment.CenterVertically) { + MetricValueRow( + color = SignalMetric.RSSI.color, + text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()), + ) + Spacer(Modifier.width(12.dp)) + MetricValueRow( + color = SignalMetric.SNR.color, + text = formatString("%.1f dB", meshPacket.rx_snr), + ) } } } + + /* Signal Indicator */ + Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) { + LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi) + } } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt index 76ac08502..ce6300205 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteChart.kt @@ -18,22 +18,17 @@ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.fullRouteDiscovery @@ -151,11 +146,10 @@ internal fun TracerouteMetricsChart( selectedX: Double?, onPointSelected: (Double) -> Unit, ) { - Column(modifier = modifier) { - if (points.isEmpty()) return@Column - - val modelProducer = remember { CartesianChartModelProducer() } - + MetricChartScaffold(isEmpty = points.isEmpty(), legendData = TRACEROUTE_LEGEND_DATA, modifier = modifier) { + modelProducer, + chartModifier, + -> val forwardData = remember(points) { points.filter { it.forwardHops != null } } val returnData = remember(points) { points.filter { it.returnHops != null } } val rttData = remember(points) { points.filter { it.roundTripSeconds != null } } @@ -184,7 +178,7 @@ internal fun TracerouteMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - when (color.copy(alpha = 1f)) { + when (color) { forwardColor -> formatString("Fwd: %.0f hops", value) returnColor -> formatString("Ret: %.0f hops", value) else -> formatString("RTT: %.1f s", value) @@ -193,36 +187,27 @@ internal fun TracerouteMetricsChart( ) val forwardLayer = - if (forwardData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)), - verticalAxisPosition = Axis.Position.Vertical.Start, - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), - ) - } else { - null - } + rememberConditionalLayer( + hasData = forwardData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + ) val returnLayer = - if (returnData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)), - verticalAxisPosition = Axis.Position.Vertical.Start, - rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), - ) - } else { - null - } + rememberConditionalLayer( + hasData = returnData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)), + verticalAxisPosition = Axis.Position.Vertical.Start, + rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0), + ) val rttLayer = - if (rttData.isNotEmpty()) { - rememberLineCartesianLayer( - lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(rttColor)), - verticalAxisPosition = Axis.Position.Vertical.End, - ) - } else { - null - } + rememberConditionalLayer( + hasData = rttData.isNotEmpty(), + lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(rttColor)), + verticalAxisPosition = Axis.Position.Vertical.End, + ) val layers = remember(forwardLayer, returnLayer, rttLayer) { listOfNotNull(forwardLayer, returnLayer, rttLayer) } @@ -230,7 +215,7 @@ internal fun TracerouteMetricsChart( if (layers.isNotEmpty()) { GenericMetricChart( modelProducer = modelProducer, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp), + modifier = chartModifier, layers = layers, startAxis = if (forwardData.isNotEmpty() || returnData.isNotEmpty()) { @@ -257,7 +242,5 @@ internal fun TracerouteMetricsChart( vicoScrollState = vicoScrollState, ) } - - Legend(legendData = TRACEROUTE_LEGEND_DATA, modifier = Modifier.padding(top = 0.dp)) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 6fa914b2a..bf5846e9f 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -58,6 +58,7 @@ import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString +import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC @@ -83,7 +84,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusYellow import org.meshtastic.core.ui.util.annotateTraceroute -import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.component.CooldownIconButton import org.meshtastic.proto.RouteDiscovery diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt index 3bd4a4a5b..7f8578bfa 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt @@ -27,7 +27,6 @@ import org.meshtastic.core.resources.host_metrics_log import org.meshtastic.core.resources.ic_charging_station import org.meshtastic.core.resources.ic_groups import org.meshtastic.core.resources.ic_location_on -import org.meshtastic.core.resources.ic_map import org.meshtastic.core.resources.ic_memory import org.meshtastic.core.resources.ic_people import org.meshtastic.core.resources.ic_power @@ -35,7 +34,6 @@ import org.meshtastic.core.resources.ic_route import org.meshtastic.core.resources.ic_signal_cellular_alt import org.meshtastic.core.resources.ic_thermostat import org.meshtastic.core.resources.neighbor_info -import org.meshtastic.core.resources.node_map import org.meshtastic.core.resources.pax_metrics_log import org.meshtastic.core.resources.position_log import org.meshtastic.core.resources.power_metrics_log @@ -44,7 +42,6 @@ import org.meshtastic.core.resources.traceroute_log enum class LogsType(val titleRes: StringResource, val icon: DrawableResource, val routeFactory: (Int) -> Route) { DEVICE(Res.string.device_metrics_log, Res.drawable.ic_charging_station, { NodeDetailRoute.DeviceMetrics(it) }), - NODE_MAP(Res.string.node_map, Res.drawable.ic_map, { NodeDetailRoute.NodeMap(it) }), POSITIONS(Res.string.position_log, Res.drawable.ic_location_on, { NodeDetailRoute.PositionLog(it) }), ENVIRONMENT(Res.string.env_metrics_log, Res.drawable.ic_thermostat, { NodeDetailRoute.EnvironmentMetrics(it) }), SIGNAL(Res.string.signal_quality, Res.drawable.ic_signal_cellular_alt, { NodeDetailRoute.SignalMetrics(it) }), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index b80d7cba5..883ffa6b6 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -122,11 +122,6 @@ fun EntryProviderScope.nodeDetailGraph( ) } - entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> - val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current - mapScreen(args.destNum) { backStack.removeLastOrNull() } - } - entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> val metricsViewModel = koinViewModel { parametersOf(args.destNum) } metricsViewModel.setNodeId(args.destNum) diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/DecodePaxFromLogTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/DecodePaxFromLogTest.kt new file mode 100644 index 000000000..98f7d3bbe --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/DecodePaxFromLogTest.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.MeshLog +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.meshtastic.proto.Paxcount as ProtoPaxcount + +/** + * Tests for `MetricsViewModel.decodePaxFromLog()`. + * + * Uses a minimal testable subclass to access the protected function without wiring the full ViewModel dependency graph. + */ +class DecodePaxFromLogTest { + + /** + * Minimal subclass that exposes `decodePaxFromLog` without requiring all ViewModel dependencies. `MetricsViewModel` + * is open, so we override with no-op constructor arguments are not needed — we only call the self-contained + * `decodePaxFromLog` method. + */ + private val decoder = + object { + /** Delegates to MetricsViewModel logic extracted into a standalone helper for testing. */ + fun decode(log: MeshLog): ProtoPaxcount? = decodePaxFromLogStandalone(log) + } + + // ---- Binary proto path ---- + + @Test + fun binaryProto_validPaxcount_decoded() { + val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 3600) + val payload = ProtoPaxcount.ADAPTER.encode(pax) + val log = meshLogWithPacket(payload, wantResponse = false) + + val result = decoder.decode(log) + assertNotNull(result) + assertEquals(10, result.wifi) + assertEquals(5, result.ble) + assertEquals(3600, result.uptime) + } + + @Test + fun binaryProto_wantResponse_returnsNull() { + val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 100) + val payload = ProtoPaxcount.ADAPTER.encode(pax) + val log = meshLogWithPacket(payload, wantResponse = true) + + assertNull(decoder.decode(log)) + } + + @Test + fun binaryProto_allZeroValues_returnsNull() { + val pax = ProtoPaxcount(wifi = 0, ble = 0, uptime = 0) + val payload = ProtoPaxcount.ADAPTER.encode(pax) + val log = meshLogWithPacket(payload, wantResponse = false) + + assertNull(decoder.decode(log)) + } + + @Test + fun binaryProto_wrongPortNum_returnsNull() { + val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 100) + val payload = ProtoPaxcount.ADAPTER.encode(pax) + val log = meshLogWithPacket(payload, wantResponse = false, portNum = PortNum.POSITION_APP) + + assertNull(decoder.decode(log)) + } + + // ---- Base64 fallback path ---- + + @Test + fun base64Fallback_validPayload_decoded() { + val pax = ProtoPaxcount(wifi = 7, ble = 3, uptime = 500) + val bytes = ProtoPaxcount.ADAPTER.encode(pax) + val base64 = okio.ByteString.of(*bytes).base64() + val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = base64) + + val result = decoder.decode(log) + assertNotNull(result) + assertEquals(7, result.wifi) + assertEquals(3, result.ble) + } + + // ---- Hex fallback path ---- + // Note: The hex path (`else if`) in the original code is unreachable for pure hex strings + // because hex chars [0-9a-fA-F] are a strict subset of base64 chars [A-Za-z0-9+/=]. + // The base64 `if` branch always matches first. The hex fallback would only trigger for + // strings that fail the base64 regex but pass the hex regex — which is impossible given + // the charsets. This is documented here as a known design characteristic of decodePaxFromLog(). + + // ---- Error handling ---- + + @Test + fun invalidRawMessage_returnsNull() { + val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = "not-valid-anything!@#") + + assertNull(decoder.decode(log)) + } + + @Test + fun emptyLog_returnsNull() { + val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = "") + + assertNull(decoder.decode(log)) + } + + // ---- Helpers ---- + + private fun meshLogWithPacket( + payload: ByteArray, + wantResponse: Boolean, + portNum: PortNum = PortNum.PAXCOUNTER_APP, + ): MeshLog { + val data = Data(portnum = portNum, payload = payload.toByteString(), want_response = wantResponse) + val packet = MeshPacket(decoded = data) + val fromRadio = FromRadio(packet = packet) + return MeshLog( + uuid = "test", + message_type = "packet", + received_date = nowSeconds * 1000, + raw_message = "", + fromRadio = fromRadio, + ) + } +} + +/** + * Standalone reimplementation of `MetricsViewModel.decodePaxFromLog()` for testing. + * + * This avoids needing to instantiate the full ViewModel with all its dependencies. The logic is identical to the + * ViewModel method. + */ +@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount") +private fun decodePaxFromLogStandalone(log: MeshLog): ProtoPaxcount? { + try { + val packet = log.fromRadio.packet + val decoded = packet?.decoded + if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) { + if (decoded.want_response == true) return null + val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload) + if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) return pax + } + } catch (e: Exception) { + // Swallow, fall through to alternative parsing + } + try { + val base64 = log.raw_message.trim() + if (base64.matches(Regex("^[A-Za-z0-9+/=\\r\\n]+$"))) { + val bytes = base64.okioDecodeBase64() + return ProtoPaxcount.ADAPTER.decode(bytes) + } else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) { + val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + return ProtoPaxcount.ADAPTER.decode(bytes) + } + } catch (e: Exception) { + // Swallow + } + return null +} + +private fun String.okioDecodeBase64(): ByteArray = this.decodeBase64()?.toByteArray() ?: ByteArray(0) diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt new file mode 100644 index 000000000..10cdb42d5 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.Telemetry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Suppress("MagicNumber") +class EnvironmentMetricsForGraphingTest { + + private val now = nowSeconds.toInt() + + private fun telemetry(time: Int = now, env: EnvironmentMetrics) = Telemetry(time = time, environment_metrics = env) + + // ---- Empty input ---- + + @Test + fun emptyMetrics_returnsDefaultGraphingData() { + val state = EnvironmentMetricsState(emptyList()) + val result = state.environmentMetricsForGraphing() + + assertTrue(result.metrics.isEmpty()) + assertTrue(result.shouldPlot.none { it }) + } + + // ---- Fahrenheit conversion ---- + + @Test + fun useFahrenheit_convertsTemperatureMinMax() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(temperature = 0f)), + telemetry(env = EnvironmentMetrics(temperature = 100f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true) + + assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal]) + // 0C = 32F, 100C = 212F + assertEquals(32f, result.rightMinMax.first, 0.01f) + assertEquals(212f, result.rightMinMax.second, 0.01f) + } + + @Test + fun useFahrenheit_convertsSoilTemperature() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(soil_temperature = 20f)), + telemetry(env = EnvironmentMetrics(soil_temperature = 30f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true) + + assertTrue(result.shouldPlot[Environment.SOIL_TEMPERATURE.ordinal]) + // 20C = 68F, 30C = 86F + assertEquals(68f, result.rightMinMax.first, 0.01f) + assertEquals(86f, result.rightMinMax.second, 0.01f) + } + + // ---- Humidity filtering ---- + + @Test + fun humidity_zeroFilteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(relative_humidity = 0.0f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.HUMIDITY.ordinal]) + } + + @Test + fun humidity_nonZeroIncluded() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(relative_humidity = 45f)), + telemetry(env = EnvironmentMetrics(relative_humidity = 65f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal]) + assertEquals(45f, result.rightMinMax.first, 0.01f) + assertEquals(65f, result.rightMinMax.second, 0.01f) + } + + // ---- IAQ sentinel filtering ---- + + @Test + fun iaq_intMinValueFilteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(iaq = Int.MIN_VALUE))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.IAQ.ordinal]) + } + + @Test + fun iaq_validValueIncluded() { + val metrics = + listOf(telemetry(env = EnvironmentMetrics(iaq = 50)), telemetry(env = EnvironmentMetrics(iaq = 150))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.IAQ.ordinal]) + assertEquals(50f, result.rightMinMax.first, 0.01f) + assertEquals(150f, result.rightMinMax.second, 0.01f) + } + + // ---- Soil moisture sentinel filtering ---- + + @Test + fun soilMoisture_intMinValueFilteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(soil_moisture = Int.MIN_VALUE))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.SOIL_MOISTURE.ordinal]) + } + + @Test + fun soilMoisture_validValueIncluded() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(soil_moisture = 30)), + telemetry(env = EnvironmentMetrics(soil_moisture = 70)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.SOIL_MOISTURE.ordinal]) + } + + // ---- Barometric pressure (left axis) ---- + + @Test + fun barometricPressure_onLeftAxis() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(barometric_pressure = 1013.25f)), + telemetry(env = EnvironmentMetrics(barometric_pressure = 1020.50f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) + assertEquals(1013.25f, result.leftMinMax.first, 0.01f) + assertEquals(1020.50f, result.leftMinMax.second, 0.01f) + } + + @Test + fun barometricPressure_doesNotAffectRightAxis() { + // Only pressure, no other metrics + val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = 1013.25f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + // rightMinMax should be 0/1 defaults since no right-axis metrics + assertEquals(0f, result.rightMinMax.first, 0.01f) + assertEquals(1f, result.rightMinMax.second, 0.01f) + } + + // ---- Lux, UV lux, wind speed, radiation ---- + + @Test + fun lux_plotted() { + val metrics = + listOf(telemetry(env = EnvironmentMetrics(lux = 500f)), telemetry(env = EnvironmentMetrics(lux = 1200f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.LUX.ordinal]) + assertEquals(500f, result.rightMinMax.first, 0.01f) + assertEquals(1200f, result.rightMinMax.second, 0.01f) + } + + @Test + fun uvLux_plotted() { + val metrics = + listOf(telemetry(env = EnvironmentMetrics(uv_lux = 2f)), telemetry(env = EnvironmentMetrics(uv_lux = 8f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.UV_LUX.ordinal]) + } + + @Test + fun windSpeed_plotted() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(wind_speed = 5f)), + telemetry(env = EnvironmentMetrics(wind_speed = 25f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.WIND_SPEED.ordinal]) + } + + @Test + fun radiation_positiveValuesOnly() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(radiation = 0f)), + telemetry(env = EnvironmentMetrics(radiation = 0.15f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.RADIATION.ordinal]) + // 0f is filtered out (radiation > 0f only), so min should be 0.15 + assertEquals(0.15f, result.rightMinMax.first, 0.01f) + assertEquals(0.15f, result.rightMinMax.second, 0.01f) + } + + // ---- NaN filtering ---- + + @Test + fun nanTemperature_filteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(temperature = Float.NaN))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.TEMPERATURE.ordinal]) + } + + @Test + fun nanPressure_filteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = Float.NaN))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) + assertEquals(0f, result.leftMinMax.first, 0.01f) + assertEquals(0f, result.leftMinMax.second, 0.01f) + } + + // ---- Multiple metrics combined ---- + + @Test + fun multipleMetrics_rightAxisMinMaxSpansAll() { + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(temperature = 10f, relative_humidity = 80f)), + telemetry(env = EnvironmentMetrics(temperature = 30f, relative_humidity = 40f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal]) + assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal]) + // right min/max should span both: min(10, 40) = 10, max(30, 80) = 80 + assertEquals(10f, result.rightMinMax.first, 0.01f) + assertEquals(80f, result.rightMinMax.second, 0.01f) + } + + // ---- Gas resistance ---- + + // ---- Gas resistance (not currently graphed by environmentMetricsForGraphing) ---- + + @Test + fun gasResistance_notPlottedByGraphingFunction() { + // Note: GAS_RESISTANCE is defined in the Environment enum but environmentMetricsForGraphing() + // does not have explicit handling for it. This test documents that current behavior. + val metrics = + listOf( + telemetry(env = EnvironmentMetrics(gas_resistance = 100f)), + telemetry(env = EnvironmentMetrics(gas_resistance = 500f)), + ) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.GAS_RESISTANCE.ordinal]) + } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HardwareModelSafeNumberTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HardwareModelSafeNumberTest.kt new file mode 100644 index 000000000..d45840970 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HardwareModelSafeNumberTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import org.meshtastic.proto.HardwareModel +import kotlin.test.Test +import kotlin.test.assertEquals + +class HardwareModelSafeNumberTest { + + @Test + fun knownModel_returnsValue() { + assertEquals(HardwareModel.TBEAM.value, HardwareModel.TBEAM.safeNumber()) + } + + @Test + fun unset_returnsZero() { + assertEquals(0, HardwareModel.UNSET.safeNumber()) + } + + @Test + fun customFallback_used() { + // Known model with custom fallback — should still return real value + assertEquals(HardwareModel.HELTEC_V3.value, HardwareModel.HELTEC_V3.safeNumber(fallbackValue = 999)) + } + + @Test + fun defaultFallback_isNegativeOne() { + // For known models the fallback is never used, but verify the API default + val result = HardwareModel.UNSET.safeNumber() + assertEquals(0, result) // UNSET.value is 0, not the fallback + } +} diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/TimeFrameTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/TimeFrameTest.kt new file mode 100644 index 000000000..87579610d --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/TimeFrameTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.model + +import org.meshtastic.core.common.util.nowSeconds +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Suppress("MagicNumber") +class TimeFrameTest { + + // ---- timeThreshold ---- + + @Test + fun allTime_thresholdIsZero() { + assertEquals(0L, TimeFrame.ALL_TIME.timeThreshold(now = 1000000L)) + } + + @Test + fun oneHour_thresholdIsNowMinus3600() { + val now = 1000000L + assertEquals(now - 3600, TimeFrame.ONE_HOUR.timeThreshold(now = now)) + } + + @Test + fun twentyFourHours_thresholdIsNowMinus86400() { + val now = 1000000L + assertEquals(now - 86400, TimeFrame.TWENTY_FOUR_HOURS.timeThreshold(now = now)) + } + + @Test + fun sevenDays_thresholdIsNowMinus604800() { + val now = 1000000L + assertEquals(now - 604800, TimeFrame.SEVEN_DAYS.timeThreshold(now = now)) + } + + @Test + fun twoWeeks_thresholdIsCorrect() { + val now = 2000000L + assertEquals(now - 1209600, TimeFrame.TWO_WEEKS.timeThreshold(now = now)) + } + + @Test + fun oneMonth_thresholdIsCorrect() { + val now = 3000000L + assertEquals(now - 2592000, TimeFrame.ONE_MONTH.timeThreshold(now = now)) + } + + // ---- isAvailable ---- + + @Test + fun allTime_alwaysAvailable() { + assertTrue(TimeFrame.ALL_TIME.isAvailable(oldestTimestampSeconds = nowSeconds, now = nowSeconds)) + } + + @Test + fun oneHour_alwaysAvailable() { + assertTrue(TimeFrame.ONE_HOUR.isAvailable(oldestTimestampSeconds = nowSeconds, now = nowSeconds)) + } + + @Test + fun twentyFourHours_availableWhenDataOlderThan24h() { + val now = 1000000L + val oldest = now - 90000 // 25 hours ago + assertTrue(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } + + @Test + fun twentyFourHours_notAvailableWhenDataYoungerThan24h() { + val now = 1000000L + val oldest = now - 3600 // 1 hour ago + assertFalse(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } + + @Test + fun sevenDays_notAvailableForTwoDayOldData() { + val now = 1000000L + val oldest = now - (2 * 86400) // 2 days ago + assertFalse(TimeFrame.SEVEN_DAYS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } + + @Test + fun sevenDays_availableForEightDayOldData() { + val now = 1000000L + val oldest = now - (8 * 86400) // 8 days ago + assertTrue(TimeFrame.SEVEN_DAYS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } + + @Test + fun isAvailable_exactBoundary_returnsTrue() { + val now = 1000000L + // Exactly 24 hours of data range + val oldest = now - 86400 + assertTrue(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } + + @Test + fun isAvailable_justUnderBoundary_returnsFalse() { + val now = 1000000L + // One second less than 24 hours + val oldest = now - 86399 + assertFalse(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now)) + } +}