diff --git a/androidApp/detekt-baseline.xml b/androidApp/detekt-baseline.xml index c1fd804fb9..69c4894c05 100644 --- a/androidApp/detekt-baseline.xml +++ b/androidApp/detekt-baseline.xml @@ -24,7 +24,6 @@ MatchingDeclarationName:MapView.kt:GoogleMapMode ModifierMissing:DownloadButton.kt:@Composable fun DownloadButton ModifierMissing:Main.kt:@Composable fun MainScreen - ModifierMissing:NodeMapScreen.kt:@Composable fun NodeMapScreen ModifierMissing:WaypointMarkers.kt:@OptIn(MapsComposeExperimentalApi::class) @Composable fun WaypointMarkers ReturnCount:GooglePlatformAnalytics.kt:GooglePlatformAnalytics.CrashlyticsLogWriter$override fun log ReturnCount:MapViewModel.kt:MapViewModel$fun getTileProvider: TileProvider? diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt deleted file mode 100644 index 3e854399ce..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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.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.androidCustomRasterBasemaps -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.feature.map.maplibre.MapLibreNodeTrackMap -import org.meshtastic.feature.map.node.NodeMapViewModel - -@Composable -fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val node by nodeMapViewModel.node.collectAsStateWithLifecycle() - val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - - Scaffold( - topBar = { - MainAppBar( - title = node?.user?.long_name ?: "", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = {}, - onClickChip = {}, - ) - }, - ) { paddingValues -> - // Only once the node has resolved: 0 is not a node, and a placeholder paints the track in the wrong - // colour with no head chip. - node?.let { resolved -> - MapLibreNodeTrackMap( - destNum = resolved.num, - positions = positions, - modifier = Modifier.fillMaxSize().padding(paddingValues), - customBasemaps = { androidCustomRasterBasemaps() }, - ) - } - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt deleted file mode 100644 index 19ce8cff75..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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.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 - -@Composable -fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val node by nodeMapViewModel.node.collectAsStateWithLifecycle() - val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - - Scaffold( - topBar = { - MainAppBar( - title = node?.user?.long_name ?: "", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = {}, - onClickChip = {}, - ) - }, - ) { paddingValues -> - MapView( - modifier = Modifier.fillMaxSize().padding(paddingValues), - mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions), - ) - } -} diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 4036bca174..3572943137 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -53,7 +53,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf import org.meshtastic.app.intro.AnalyticsIntro import org.meshtastic.app.map.getMapViewProvider import org.meshtastic.app.node.component.InlineMap @@ -92,12 +91,10 @@ import org.meshtastic.core.ui.util.LocalNfcEmulatorProvider import org.meshtastic.core.ui.util.LocalNfcScannerProvider import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.core.ui.util.LocalNfcWriterProvider -import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider import org.meshtastic.core.ui.util.LocalSitePlannerAvailable 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.accentColorOrNull import org.meshtastic.core.ui.util.brandHighlightOrNull import org.meshtastic.core.ui.util.brandPalette @@ -109,9 +106,6 @@ import org.meshtastic.feature.map.MapScreen import org.meshtastic.feature.map.SharedMapViewModel import org.meshtastic.feature.map.layers.MapLayersManager import org.meshtastic.feature.map.layers.toPickedMapFile -import org.meshtastic.feature.map.node.NodeMapViewModel -import org.meshtastic.feature.node.metrics.MetricsViewModel -import org.meshtastic.feature.node.metrics.TracerouteMapScreen class MainActivity : AppCompatActivity() { private val model: UIViewModel by viewModel() @@ -283,24 +277,6 @@ class MainActivity : AppCompatActivity() { { userLat, userLon, nodes, modifier -> org.meshtastic.app.map.discovery.DiscoveryMap(userLat, userLon, nodes, modifier) }, - LocalNodeMapScreenProvider provides - { destNum, onNavigateUp -> - val vm = koinViewModel() - vm.setDestNum(destNum) - org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp) - }, - LocalTracerouteMapScreenProvider provides - { destNum, requestId, logUuid, onNavigateUp -> - val metricsViewModel = koinViewModel { parametersOf(destNum) } - metricsViewModel.setNodeId(destNum) - - TracerouteMapScreen( - metricsViewModel = metricsViewModel, - requestId = requestId, - logUuid = logUuid, - onNavigateUp = onNavigateUp, - ) - }, LocalMapMainScreenProvider provides { onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum -> val viewModel = koinViewModel() diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml index fd2d325511..8c44ce91a6 100644 --- a/core/ui/detekt-baseline.xml +++ b/core/ui/detekt-baseline.xml @@ -23,11 +23,9 @@ CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerProvider = compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } } CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerSupported = compositionLocalOf { false } CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcWriterProvider = compositionLocalOf<@Composable (url: String, onResult: (Boolean) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _, _ -> } } - CompositionLocalAllowlist:LocalNodeMapScreenProvider.kt:/** * Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM * targets where native maps aren't available yet, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeMapScreenProvider = compositionLocalOf<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> { { destNum, _ -> PlaceholderScreen("Node Map ($destNum)") } } - CompositionLocalAllowlist:LocalNodeTrackMapProvider.kt:/** * 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). * * Supports optional synchronized selection: * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When * non-null, the map should visually highlight the corresponding marker and center the camera on it. * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so * the host can synchronize the card list. * - [showAttribution]: whether the credit opens with the map or stays collapsed behind its own button. The embedded * instance collapses it; a full-screen one does not. * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeTrackMapProvider = compositionLocalOf< @Composable ( destNum: Int, positions: List<Position>, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -> Unit)?, showAttribution: Boolean, ) -> Unit, > { { _, _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } } + CompositionLocalAllowlist:LocalNodeTrackMapProvider.kt:/** * Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions]. * It has no Scaffold or AppBar, so it can be embedded inside another screen layout (e.g. the position-log adaptive * layout). * * Supports optional synchronized selection: * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When * non-null, the map should visually highlight the corresponding marker and center the camera on it. * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so * the host can synchronize the card list. * - [showAttribution]: whether the credit opens with the map or stays collapsed behind its own button. The embedded * instance collapses it; a full-screen one does not. * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeTrackMapProvider = compositionLocalOf< @Composable ( destNum: Int, positions: List<Position>, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -> Unit)?, showAttribution: Boolean, ) -> Unit, > { { _, _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } } CompositionLocalAllowlist:LocalTracerouteMapOverlayInsetsProvider.kt:val LocalTracerouteMapOverlayInsetsProvider = compositionLocalOf { TracerouteMapOverlayInsets() } CompositionLocalAllowlist:LocalTracerouteMapProvider.kt:/** * 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<Int, Position>, onMappableCountChanged: (Int, Int) -> Unit, modifier: Modifier, ) -> Unit, > { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } } - CompositionLocalAllowlist:LocalTracerouteMapScreenProvider.kt:/** * Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available * yet, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalTracerouteMapScreenProvider = compositionLocalOf<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } } CompositionLocalAllowlist:MapViewProvider.kt:val LocalMapViewProvider = compositionLocalOf<MapViewProvider?> { null } FunctionTypeModifierSpacing:Theme.kt:@Composable() LambdaParameterInRestartableEffect:EmojiPickerDialog.kt:onCategoryChanged: (Int) -> Unit diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt deleted file mode 100644 index 7e54003a52..0000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 org.meshtastic.core.ui.component.PlaceholderScreen - -/** - * Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM - * targets where native maps aren't available yet, it falls back to a [PlaceholderScreen]. - */ -@Suppress("Wrapping") -val LocalNodeMapScreenProvider = - compositionLocalOf<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> { - { destNum, _ -> PlaceholderScreen("Node Map ($destNum)") } - } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt index 8319ec11e5..6f1ea6d7c4 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt @@ -24,8 +24,8 @@ 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). + * It has no Scaffold or AppBar, so it can be embedded inside another screen layout (e.g. the position-log adaptive + * layout). * * Supports optional synchronized selection: * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt deleted file mode 100644 index 26eb02b7e8..0000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 org.meshtastic.core.ui.component.PlaceholderScreen - -/** - * Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available - * yet, it falls back to a [PlaceholderScreen]. - */ -@Suppress("Wrapping") -val LocalTracerouteMapScreenProvider = - compositionLocalOf<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> { - { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } - } diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt index 4530b31116..3d9fe0a654 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -17,6 +17,7 @@ package org.meshtastic.desktop import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -105,7 +106,9 @@ import org.meshtastic.core.ui.util.LocalMapMainScreenProvider import org.meshtastic.core.ui.util.LocalMapViewProvider import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider import org.meshtastic.core.ui.util.LocalSitePlannerAvailable +import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider import org.meshtastic.core.ui.util.LocalTracerouteMapProvider +import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets import org.meshtastic.core.ui.util.rememberOpenUrl import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.desktop.data.DesktopPreferencesDataSource @@ -471,6 +474,9 @@ private fun ApplicationScope.MeshtasticWindow( { overlay, nodePositions, onMappableCountChanged, modifier -> DesktopTracerouteMap(overlay, nodePositions, onMappableCountChanged, modifier) }, + // Clear of the MapLibre logo and attribution row along the bottom edge. + LocalTracerouteMapOverlayInsetsProvider provides + TracerouteMapOverlayInsets(overlayPadding = PaddingValues(bottom = 48.dp)), ) { AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) } } diff --git a/docs/en/user/node-metrics.md b/docs/en/user/node-metrics.md index afe552ffe8..9d10a36a2b 100644 --- a/docs/en/user/node-metrics.md +++ b/docs/en/user/node-metrics.md @@ -2,7 +2,7 @@ title: Node Metrics parent: User Guide nav_order: 5 -last_updated: 2026-09-09 +last_updated: 2026-09-28 description: Telemetry dashboards for each mesh node — device health, environment sensors, air quality, signal quality, power, traceroute, and position history. aliases: - metrics @@ -152,7 +152,7 @@ Traceroute shows the path a message takes through the mesh: ### Reading Traceroute Results -A traceroute is a round trip, so each saved result carries a hop count in each direction — **Forward Hops** and **Return Hops** — and the **Round Trip** time in seconds. A result marked **Direct** reached the target with no relay in between. Tap a result to read the route traced toward the destination and the route traced back to you, with the SNR of every hop. On Android that view offers **View on map**, which draws the same path, as long as the start and destination nodes have both shared a position. +A traceroute is a round trip, so each saved result carries a hop count in each direction, **Forward Hops** and **Return Hops**, plus the **Round Trip** time in seconds. A result marked **Direct** reached the target with no relay in between. Tap a result to read the route traced toward the destination and the route traced back to you, with the SNR of every hop. That view offers **View on map**, which draws the same path, as long as the start and destination nodes have both shared a position. A result marked **No Response** means the target never answered. It may be out of range, asleep, or configured not to reply. Wait for the 30-second cooldown to clear and try again; if it keeps failing, send a direct message first to confirm the node is reachable at all. diff --git a/feature/map/README.md b/feature/map/README.md index 3010765b84..a8d20a9d22 100644 --- a/feature/map/README.md +++ b/feature/map/README.md @@ -23,7 +23,7 @@ All providers are injected via `CompositionLocal` — in `MainActivity.kt` on An ### 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). +- **`NodeMapViewModel`**: resolves a node number to its `Node` for the Google flavor's embedded node-track map. ### Shared Logic (in `commonMain`) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt index 6204dc5ecf..e8d219ba2a 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt @@ -19,31 +19,16 @@ package org.meshtastic.feature.map.node import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.toList import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.model.MeshLog -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.ui.util.toPosition import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.Position @KoinViewModel -class NodeMapViewModel( - savedStateHandle: SavedStateHandle, - nodeRepository: NodeRepository, - meshLogRepository: MeshLogRepository, - private val mapPrefs: MapPrefs, -) : ViewModel() { +class NodeMapViewModel(savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository) : ViewModel() { private val destNumFromRoute = savedStateHandle.get("destNum") private val manualDestNum = MutableStateFlow(null) @@ -59,28 +44,4 @@ class NodeMapViewModel( .flatMapLatest { destNum -> nodeRepository.nodeDBbyNum.mapLatest { it[destNum] } } .distinctUntilChanged() .stateInWhileSubscribed(initialValue = null) - - private val ourNodeNumFlow = nodeRepository.myNodeInfo.map { it?.myNodeNum }.distinctUntilChanged() - - val positionLogs: StateFlow> = - combine(ourNodeNumFlow, destNumFlow) { ourNodeNum, destNum -> - if (destNum == ourNodeNum) MeshLog.NODE_NUM_LOCAL else destNum - } - .distinctUntilChanged() - .flatMapLatest { logId -> - meshLogRepository.getMeshPacketsFrom(logId, PortNum.POSITION_APP.value).map { packets -> - packets - .mapNotNull { it.toPosition() } - .asFlow() - .distinctUntilChanged { old, new -> - old.time == new.time || - (old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i) - } - .toList() - } - } - .stateInWhileSubscribed(initialValue = emptyList()) - - val mapStyleId: Int - get() = mapPrefs.mapStyle.value } diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt similarity index 100% rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt rename to feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt 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 6d5a618794..ee312702b4 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 @@ -69,6 +69,7 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen import org.meshtastic.feature.node.metrics.PowerMetricsScreen import org.meshtastic.feature.node.metrics.SignalMetricsScreen import org.meshtastic.feature.node.metrics.TracerouteLogScreen +import org.meshtastic.feature.node.metrics.TracerouteMapScreen import kotlin.reflect.KClass @OptIn(ExperimentalMaterial3AdaptiveApi::class) @@ -128,12 +129,14 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack) } entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args -> - val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current - tracerouteMapScreen( - args.destNum, - args.requestId, - args.logUuid, - dropUnlessResumed { backStack.removeLastOrNull() }, + val metricsViewModel = koinViewModel { parametersOf(args.destNum) } + metricsViewModel.setNodeId(args.destNum) + + TracerouteMapScreen( + metricsViewModel = metricsViewModel, + requestId = args.requestId, + logUuid = args.logUuid, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, ) } @@ -188,7 +191,6 @@ private inline fun EntryProviderScope.addNodeDetailS } } -/** Expect declaration for the platform-specific traceroute map screen. */ enum class NodeDetailScreen( val title: StringResource, val routeClass: KClass, diff --git a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapRouteTest.kt b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapRouteTest.kt new file mode 100644 index 0000000000..e175bec006 --- /dev/null +++ b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapRouteTest.kt @@ -0,0 +1,186 @@ +/* + * 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.navigation + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.koin.compose.KoinIsolatedContext +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.koinApplication +import org.koin.dsl.module +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.Node +import org.meshtastic.core.navigation.NodeDetailRoute +import org.meshtastic.core.repository.FileService +import org.meshtastic.core.repository.MeshLogRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.TracerouteResponseProvider +import org.meshtastic.core.repository.TracerouteSnapshotRepository +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.traceroute_showing_nodes +import org.meshtastic.core.ui.component.MeshtasticNavDisplay +import org.meshtastic.core.ui.util.AlertManager +import org.meshtastic.core.ui.util.LocalTracerouteMapProvider +import org.meshtastic.feature.node.detail.NodeDetailUiState +import org.meshtastic.feature.node.detail.NodeRequestActions +import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase +import org.meshtastic.feature.node.metrics.MetricsViewModel +import org.meshtastic.feature.node.model.MetricsState +import org.meshtastic.proto.User +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTestApi::class) +class TracerouteMapRouteTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun tracerouteMapRouteRendersTheMapScreenAroundTheHostMap() = runComposeUiTest { + val route = NodeDetailRoute.TracerouteMap(destNum = NODE_NUM, requestId = REQUEST_ID) + var factoryDestNum: Int? = null + val koinApp = koinApplication { + modules( + module { + viewModel { params -> + createMetricsViewModel(destNum = params.get().also { factoryDestNum = it }) + } + }, + ) + } + val viewModelStoreOwner = TestViewModelStoreOwner() + val lifecycleOwner = ResumedLifecycleOwner() + try { + setContent { + val backStack = remember { NavBackStack().apply { add(route) } } + KoinIsolatedContext(context = koinApp) { + CompositionLocalProvider( + LocalViewModelStoreOwner provides viewModelStoreOwner, + LocalLifecycleOwner provides lifecycleOwner, + LocalTracerouteMapProvider provides + { _, _, onMappableCountChanged, modifier -> + LaunchedEffect(Unit) { onMappableCountChanged(SHOWN_NODES, TOTAL_NODES) } + Text(text = FAKE_MAP, modifier = modifier) + }, + ) { + MaterialTheme { + MeshtasticNavDisplay( + backStack = backStack, + entryProvider = entryProvider { nodeDetailGraph(backStack) }, + ) + } + } + } + } + + onNodeWithText(PLACEHOLDER).assertDoesNotExist() + onNodeWithText(FAKE_MAP).assertExists() + onNodeWithText(NODE_NAME).assertExists() + onNodeWithText(getString(Res.string.traceroute_showing_nodes, SHOWN_NODES, TOTAL_NODES)).assertExists() + assertEquals(NODE_NUM, factoryDestNum) + } finally { + viewModelStoreOwner.viewModelStore.clear() + koinApp.close() + } + } + + private fun createMetricsViewModel(destNum: Int): MetricsViewModel { + val tracerouteResponseProvider: TracerouteResponseProvider = mock() + every { tracerouteResponseProvider.tracerouteResponse } returns MutableStateFlow(null) + every { tracerouteResponseProvider.clearTracerouteResponse() } returns Unit + val nodeRequestActions: NodeRequestActions = mock() + every { nodeRequestActions.lastTracerouteTime } returns MutableStateFlow(null) + every { nodeRequestActions.lastRequestNeighborTimes } returns MutableStateFlow(emptyMap()) + val nodeRepository: NodeRepository = mock() + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) + val node = Node(num = NODE_NUM, user = User.Builder().also { wb -> wb.long_name = NODE_NAME }.build()) + val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock() + every { getNodeDetailsUseCase(NODE_NUM) } returns + flowOf(NodeDetailUiState(node = node, metricsState = MetricsState(node = node))) + + return MetricsViewModel( + destNum = destNum, + dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher), + meshLogRepository = mock(), + tracerouteResponseProvider = tracerouteResponseProvider, + nodeRepository = nodeRepository, + tracerouteSnapshotRepository = mock(), + nodeRequestActions = nodeRequestActions, + alertManager = mock(), + getNodeDetailsUseCase = getNodeDetailsUseCase, + fileService = mock(), + ) + } + + private class TestViewModelStoreOwner : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + + private class ResumedLifecycleOwner : LifecycleOwner { + override val lifecycle: LifecycleRegistry = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + } + + private companion object { + const val NODE_NUM = 1234 + const val NODE_NAME = "Route target" + const val REQUEST_ID = 42 + const val SHOWN_NODES = 2 + const val TOTAL_NODES = 3 + const val FAKE_MAP = "host traceroute map" + const val PLACEHOLDER = "Traceroute Map" + } +}