From 3d65da72e7ca25a515934c6d7850c712ddf8bce7 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 12 Apr 2026 18:25:15 -0500 Subject: [PATCH 01/23] feat(map): replace Google Maps + OSMDroid with unified MapLibre Compose Multiplatform Replace the dual flavor-specific map implementations (Google Maps for google, OSMDroid for fdroid) with a single MapLibre Compose Multiplatform implementation in feature:map/commonMain, eliminating ~8,500 lines of duplicated code. Key changes: - Add maplibre-compose v0.12.1 dependency (KMP: Android, Desktop, iOS) - Create unified MapViewModel with camera persistence via MapCameraPrefs - Create MapScreen, MaplibreMapContent, NodeTrackLayers, TracerouteLayers, InlineMap, NodeTrackMap, TracerouteMap, NodeMapScreen in commonMain - Create MapStyle enum with predefined OpenFreeMap tile styles - Create GeoJsonConverters for Node/Waypoint/Position to GeoJSON - Move TracerouteMapScreen from feature:node/androidMain to commonMain - Wire navigation to use direct imports instead of CompositionLocal providers - Delete 61 flavor-specific map files (google + fdroid source sets) - Remove 8 CompositionLocal map providers from core:ui - Remove SharedMapViewModel (replaced by new MapViewModel) - Remove dead google-maps and osmdroid entries from version catalog - Add MapViewModelTest with 10 test cases in commonTest Baseline verified: spotlessCheck, detekt, assembleGoogleDebug, allTests all pass. --- androidApp/build.gradle.kts | 10 +- .../app/map/FdroidMapViewProvider.kt | 39 - .../meshtastic/app/map/GetMapViewProvider.kt | 21 - .../kotlin/org/meshtastic/app/map/MapUtils.kt | 80 -- .../kotlin/org/meshtastic/app/map/MapView.kt | 968 -------------- .../meshtastic/app/map/MapViewExtensions.kt | 145 --- .../org/meshtastic/app/map/MapViewModel.kt | 67 - .../app/map/MapViewWithLifecycle.kt | 136 -- .../meshtastic/app/map/SqlTileWriterExt.kt | 111 -- .../app/map/component/CacheLayout.kt | 94 -- .../app/map/component/DownloadButton.kt | 59 - .../app/map/component/EditWaypointDialog.kt | 357 ------ .../app/map/model/CustomTileSource.kt | 150 --- .../app/map/model/MarkerWithLabel.kt | 138 -- .../app/map/model/NOAAWmsTileSource.kt | 160 --- .../app/map/model/OnlineTileSourceAuth.kt | 65 - .../meshtastic/app/map/node/NodeMapScreen.kt | 54 - .../meshtastic/app/map/node/NodeTrackMap.kt | 50 - .../app/map/node/NodeTrackOsmMap.kt | 162 --- .../app/map/traceroute/TracerouteMap.kt | 41 - .../app/map/traceroute/TracerouteOsmMap.kt | 289 ----- .../app/node/component/InlineMap.kt | 64 - .../metrics/TracerouteMapOverlayInsets.kt | 28 - .../org/meshtastic/app/di/FlavorModule.kt | 3 +- .../meshtastic/app/map/GetMapViewProvider.kt | 21 - .../app/map/GoogleMapViewProvider.kt | 39 - .../org/meshtastic/app/map/LocationHandler.kt | 139 -- .../org/meshtastic/app/map/MBTilesProvider.kt | 65 - .../kotlin/org/meshtastic/app/map/MapView.kt | 1126 ----------------- .../org/meshtastic/app/map/MapViewModel.kt | 690 ---------- .../map/component/ClusterItemsListDialog.kt | 76 -- .../app/map/component/CustomMapLayersSheet.kt | 216 ---- .../CustomTileProviderManagerSheet.kt | 324 ----- .../app/map/component/EditWaypointDialog.kt | 372 ------ .../app/map/component/MapFilterDropdown.kt | 165 --- .../app/map/component/MapTypeDropdown.kt | 120 -- .../app/map/component/NodeClusterMarkers.kt | 114 -- .../app/map/component/PulsingNodeChip.kt | 68 - .../app/map/component/WaypointMarkers.kt | 79 -- .../app/map/model/CustomTileProviderConfig.kt | 31 - .../app/map/model/CustomTileSource.kt | 26 - .../app/map/model/NodeClusterItem.kt | 62 - .../meshtastic/app/map/node/NodeTrackMap.kt | 58 - .../app/map/prefs/di/GoogleMapsKoinModule.kt | 45 - .../app/map/prefs/map/GoogleMapsPrefs.kt | 196 --- .../CustomTileProviderRepository.kt | 104 -- .../app/map/traceroute/TracerouteMap.kt | 46 - .../app/node/component/InlineMap.kt | 84 -- .../metrics/TracerouteMapOverlayInsets.kt | 28 - .../kotlin/org/meshtastic/app/MainActivity.kt | 66 - .../meshtastic/app/di/KoinVerificationTest.kt | 2 +- .../core/prefs/map/MapCameraPrefsImpl.kt | 126 ++ .../core/repository/AppPreferences.kt | 36 + .../composeResources/values/strings.xml | 41 + .../core/testing/FakeAppPreferences.kt | 52 + .../core/ui/util/LocalInlineMapProvider.kt | 24 - .../ui/util/LocalMapMainScreenProvider.kt | 33 - .../ui/util/LocalNodeMapScreenProvider.kt | 31 - .../core/ui/util/LocalNodeTrackMapProvider.kt | 50 - ...LocalTracerouteMapOverlayInsetsProvider.kt | 30 - .../ui/util/LocalTracerouteMapProvider.kt | 51 - .../util/LocalTracerouteMapScreenProvider.kt | 31 - .../core/ui/util/MapViewProvider.kt | 31 - feature/map/build.gradle.kts | 2 + .../org/meshtastic/feature/map/MapScreen.kt | 64 - .../feature/map/MBTilesProviderTest.kt | 63 - .../feature/map/MapViewModelTest.kt | 154 --- .../org/meshtastic/feature/map/MapScreen.kt | 105 ++ .../meshtastic/feature/map/MapViewModel.kt | 100 ++ .../feature/map/SharedMapViewModel.kt | 31 - .../feature/map/component/InlineMap.kt | 106 ++ .../map/component/MaplibreMapContent.kt | 205 +++ .../feature/map/component/NodeTrackLayers.kt | 103 ++ .../feature/map/component/NodeTrackMap.kt | 72 ++ .../feature/map/component/TracerouteLayers.kt | 193 +++ .../feature/map/component/TracerouteMap.kt | 75 ++ .../meshtastic/feature/map/model/MapStyle.kt | 52 + .../feature/map/navigation/MapNavigation.kt | 14 +- .../feature}/map/node/NodeMapScreen.kt | 29 +- .../feature/map/util/GeoJsonConverters.kt | 178 +++ .../feature/map/MapViewModelTest.kt | 184 +++ .../feature/node/component/PositionSection.kt | 4 +- .../node/metrics/PositionLogScreens.kt | 12 +- .../node/metrics/TracerouteMapScreen.kt | 18 +- .../node/navigation/NodesNavigation.kt | 14 +- gradle/libs.versions.toml | 13 +- 86 files changed, 1688 insertions(+), 8292 deletions(-) delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt delete mode 100644 androidApp/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt create mode 100644 core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt delete mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt delete mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt delete mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt delete mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt delete mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt delete mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt delete mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt delete mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt delete mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt delete mode 100644 feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt delete mode 100644 feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt delete mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt rename {androidApp/src/google/kotlin/org/meshtastic/app => feature/map/src/commonMain/kotlin/org/meshtastic/feature}/map/node/NodeMapScreen.kt (60%) create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt rename feature/node/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt (91%) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 480989d8a1..81896d0102 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -263,11 +263,7 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.glance.preview) - googleImplementation(libs.location.services) - googleImplementation(libs.play.services.maps) - googleImplementation(libs.maps.compose) - googleImplementation(libs.maps.compose.utils) - googleImplementation(libs.maps.compose.widgets) + googleImplementation(libs.dd.sdk.android.compose) googleImplementation(libs.dd.sdk.android.logs) googleImplementation(libs.dd.sdk.android.rum) googleImplementation(libs.dd.sdk.android.session.replay) @@ -282,10 +278,6 @@ dependencies { googleImplementation(libs.firebase.ai.ondevice) googleImplementation(libs.mlkit.translate) - fdroidImplementation(libs.osmdroid.android) - fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } - fdroidImplementation(libs.osmbonuspack) - testImplementation(kotlin("test-junit")) testImplementation(libs.androidx.work.testing) testImplementation(libs.koin.test) diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt deleted file mode 100644 index 21c2d4fdea..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt +++ /dev/null @@ -1,39 +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 - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -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, 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, - ) - } -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt deleted file mode 100644 index 48b1aa7fc3..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt +++ /dev/null @@ -1,21 +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 - -import org.meshtastic.core.ui.util.MapViewProvider - -fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider() diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt deleted file mode 100644 index 246d33d78e..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt +++ /dev/null @@ -1,80 +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 - -import android.content.Context -import android.util.TypedValue -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import kotlin.math.log2 -import kotlin.math.pow - -private const val DEGREES_IN_CIRCLE = 360.0 -private const val METERS_PER_DEGREE_LATITUDE = 111320.0 -private const val ZOOM_ADJUSTMENT_FACTOR = 0.8 - -/** - * Calculates the zoom level required to fit the entire [BoundingBox] inside the map view. - * - * @return The zoom level as a Double value. - */ -fun BoundingBox.requiredZoomLevel(): Double { - val topLeft = GeoPoint(this.latNorth, this.lonWest) - val bottomRight = GeoPoint(this.latSouth, this.lonEast) - val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude)) - val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude)) - val requiredLatZoom = log2(DEGREES_IN_CIRCLE / (latLonHeight / METERS_PER_DEGREE_LATITUDE)) - val requiredLonZoom = log2(DEGREES_IN_CIRCLE / (latLonWidth / METERS_PER_DEGREE_LATITUDE)) - return maxOf(requiredLatZoom, requiredLonZoom) * ZOOM_ADJUSTMENT_FACTOR -} - -/** - * Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor]. - * - * @return A new [BoundingBox] with added [zoomFactor]. Example: - * ``` - * // Setting the zoom level directly using setZoom() - * map.setZoom(14.0) - * val boundingBoxZoom14 = map.boundingBox - * - * // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0) - * val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0) - * ``` - */ -fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox { - val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2) - val latDiff = latNorth - latSouth - val lonDiff = lonEast - lonWest - - val newLatDiff = latDiff / (2.0.pow(zoomFactor)) - val newLonDiff = lonDiff / (2.0.pow(zoomFactor)) - - return BoundingBox( - center.latitude + newLatDiff / 2, - center.longitude + newLonDiff / 2, - center.latitude - newLatDiff / 2, - center.longitude - newLonDiff / 2, - ) -} - -// Converts SP to pixels. -fun Context.spToPx(sp: Float): Int = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics).toInt() - -// Converts DP to pixels. -fun Context.dpToPx(dp: Float): Int = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt() diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt deleted file mode 100644 index cebaf39316..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ /dev/null @@ -1,968 +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 - -import android.Manifest -import androidx.appcompat.content.res.AppCompatResources -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuGroup -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuDefaults -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 -import androidx.compose.runtime.Composable -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 -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.touchlab.kermit.Logger -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.viewmodel.koinViewModel -import org.meshtastic.app.R -import org.meshtastic.app.map.cluster.RadiusMarkerClusterer -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.model.CustomTileSource -import org.meshtastic.app.map.model.MarkerWithLabel -import org.meshtastic.core.common.gpsDisabled -import org.meshtastic.core.common.util.DateFormatter -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Node -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.calculating -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.clear -import org.meshtastic.core.resources.close -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 -import org.meshtastic.core.resources.map_cache_size -import org.meshtastic.core.resources.map_cache_tiles -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_node_popup_details -import org.meshtastic.core.resources.map_offline_manager -import org.meshtastic.core.resources.map_purge_fail -import org.meshtastic.core.resources.map_purge_success -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.show_precision_circle -import org.meshtastic.core.resources.show_waypoints -import org.meshtastic.core.resources.waypoint_delete -import org.meshtastic.core.resources.you -import org.meshtastic.core.ui.component.BasicListItem -import org.meshtastic.core.ui.component.ListItem -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.MeshtasticIcons -import org.meshtastic.core.ui.icon.PinDrop -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.component.MapButton -import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.Waypoint -import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable -import org.osmdroid.config.Configuration -import org.osmdroid.events.MapEventsReceiver -import org.osmdroid.events.MapListener -import org.osmdroid.events.ScrollEvent -import org.osmdroid.events.ZoomEvent -import org.osmdroid.tileprovider.cachemanager.CacheManager -import org.osmdroid.tileprovider.modules.SqliteArchiveTileWriter -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicyException -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -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.infowindow.InfoWindow -import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay -import java.io.File -import kotlin.math.roundToInt - -private fun MapView.updateMarkers( - nodeMarkers: List, - waypointMarkers: List, - nodeClusterer: RadiusMarkerClusterer, -) { - Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" } - - overlays.removeAll { overlay -> - overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items) - } - - overlays.addAll(waypointMarkers) - - nodeClusterer.items.clear() - nodeClusterer.items.addAll(nodeMarkers) - nodeClusterer.invalidate() -} - -private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) -> Unit) = - object : CacheManager.CacheManagerCallback { - override fun onTaskComplete() { - onTaskComplete() - } - - override fun onTaskFailed(errors: Int) { - onTaskFailed(errors) - } - - override fun updateProgress(progress: Int, currentZoomLevel: Int, zoomMin: Int, zoomMax: Int) { - // NOOP since we are using the build in UI - } - - override fun downloadStarted() { - // NOOP since we are using the build in UI - } - - override fun setPossibleTilesInArea(total: Int) { - // NOOP since we are using the build in UI - } - } - -/** - * Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user - * interactions for map manipulation, filtering, and offline caching. - * - * @param mapViewModel The [MapViewModel] providing data and state for the map. - * @param navigateToNodeDetails Callback to navigate to the details screen of a selected node. - */ -@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist -@Suppress("CyclomaticComplexMethod", "LongMethod") -@Composable -fun MapView( - modifier: Modifier = Modifier, - mapViewModel: MapViewModel = koinViewModel(), - navigateToNodeDetails: (Int) -> Unit, -) { - var mapFilterExpanded by remember { mutableStateOf(false) } - - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() - - var cacheEstimate by remember { mutableStateOf("") } - - var zoomLevelMin by remember { mutableDoubleStateOf(0.0) } - var zoomLevelMax by remember { mutableDoubleStateOf(0.0) } - - var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) } - var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) } - - var showDownloadButton: Boolean by remember { mutableStateOf(false) } - var showEditWaypointDialog by remember { mutableStateOf(null) } - var showCacheManagerDialog by remember { mutableStateOf(false) } - var showCurrentCacheInfo by remember { mutableStateOf(false) } - var showPurgeTileSourceDialog by remember { mutableStateOf(false) } - var showMapStyleDialog by remember { mutableStateOf(false) } - - val scope = rememberCoroutineScope() - val context = LocalContext.current - val density = LocalDensity.current - - val haptic = LocalHapticFeedback.current - fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress) - - // Accompanist permissions state for location - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } - - fun loadOnlineTileSourceBase(): ITileSource { - val id = mapViewModel.mapStyleId - Logger.d { "mapStyleId from prefs: $id" } - return CustomTileSource.getTileSource(id).also { - zoomLevelMax = it.maximumZoomLevel.toDouble() - showDownloadButton = if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false - } - } - - val initialCameraView = remember { - val nodes = mapViewModel.nodes.value - val nodesWithPosition = nodes.filter { it.validPosition != null } - val geoPoints = nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) } - BoundingBox.fromGeoPoints(geoPoints) - } - val map = - rememberMapViewWithLifecycle( - applicationId = mapViewModel.applicationId, - box = initialCameraView, - tileSource = loadOnlineTileSourceBase(), - ) - - val nodeClusterer = remember { RadiusMarkerClusterer(context) } - - fun MapView.toggleMyLocation() { - if (context.gpsDisabled()) { - Logger.d { "Telling user we need location turned on for MyLocationNewOverlay" } - scope.launch { context.showToast(Res.string.location_disabled) } - return - } - - Logger.d { "user clicked MyLocationNewOverlay ${myLocationOverlay == null}" } - if (myLocationOverlay == null) { - myLocationOverlay = - MyLocationNewOverlay(this).apply { - enableMyLocation() - enableFollowLocation() - getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot)?.let { - setPersonIcon(it) - setPersonAnchor(0.5f, 0.5f) - } - getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation)?.let { - setDirectionIcon(it) - setDirectionAnchor(0.5f, 0.5f) - } - } - overlays.add(myLocationOverlay) - } else { - myLocationOverlay?.apply { - disableMyLocation() - disableFollowLocation() - } - overlays.remove(myLocationOverlay) - myLocationOverlay = null - } - } - - // Effect to toggle MyLocation after permission is granted - LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { - map.toggleMyLocation() - triggerLocationToggleAfterPermission = false - } - } - - // 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() - val myId by mapViewModel.myId.collectAsStateWithLifecycle() - - LaunchedEffect(selectedWaypointId, waypoints) { - if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) { - waypoints[selectedWaypointId]?.waypoint?.let { pt -> - val geoPoint = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) - map.controller.setCenter(geoPoint) - map.controller.setZoom(WAYPOINT_ZOOM) - } - } - } - - 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 ?: 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.lastHeardFilter.seconds != 0L && - (nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds && - node.num != ourNode?.num - ) { - return@mapNotNull null - } - - val (p, u) = node.position to node.user - val nodePosition = GeoPoint(node.latitude, node.longitude) - MarkerWithLabel(mapView = this, label = "${u.short_name} ${formatAgo(p.time)}").apply { - id = u.id - title = u.long_name - snippet = - getString( - Res.string.map_node_popup_details, - node.gpsString(), - formatAgo(node.lastHeard), - formatAgo(p.time), - if (node.batteryStr != "") node.batteryStr else "?", - ) - ourNode?.distanceStr(node, displayUnits)?.let { dist -> - ourNode.bearing(node)?.let { bearing -> - subDescription = getString(Res.string.map_subDescription, bearing, dist) - } - } - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - position = nodePosition - icon = markerIcon - setNodeColors(node.colors) - if (!mapFilterStateValue.showPrecisionCircle) { - setPrecisionBits(0) - } else { - setPrecisionBits(p.precision_bits) - } - setOnLongClickListener { - navigateToNodeDetails(node.num) - true - } - } - } - } - - fun showDeleteMarkerDialog(waypoint: Waypoint) { - val builder = MaterialAlertDialogBuilder(context) - builder.setTitle(getString(Res.string.waypoint_delete)) - builder.setNeutralButton(getString(Res.string.cancel)) { _, _ -> - Logger.d { "User canceled marker delete dialog" } - } - builder.setNegativeButton(getString(Res.string.delete_for_me)) { _, _ -> - Logger.d { "User deleted waypoint ${waypoint.id} for me" } - mapViewModel.deleteWaypoint(waypoint.id) - } - if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { - builder.setPositiveButton(getString(Res.string.delete_for_everyone)) { _, _ -> - Logger.d { "User deleted waypoint ${waypoint.id} for everyone" } - mapViewModel.sendWaypoint(waypoint.copy(expire = 1)) - mapViewModel.deleteWaypoint(waypoint.id) - } - } - val dialog = builder.show() - for ( - button in - setOf( - androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL, - androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE, - androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE, - ) - ) { - with(dialog.getButton(button)) { - textSize = 12F - isAllCaps = false - } - } - } - - fun showMarkerLongPressDialog(id: Int) { - performHapticFeedback() - Logger.d { "marker long pressed id=$id" } - val waypoint = waypoints[id]?.waypoint ?: return - // edit only when unlocked or lockedTo myNodeNum - if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { - showEditWaypointDialog = waypoint - } else { - showDeleteMarkerDialog(waypoint) - } - } - - fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) { - getString(Res.string.you) - } else { - mapViewModel.getUser(id).long_name - } - - @Suppress("MagicNumber") - fun MapView.onWaypointChanged(waypoints: Collection, selectedWaypointId: Int?): List { - return waypoints.mapNotNull { waypoint -> - val pt = waypoint.waypoint ?: return@mapNotNull null - if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState - val lock = if (pt.locked_to != 0) "\uD83D\uDD12" else "" - val time = DateFormatter.formatDateTime(waypoint.time) - val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt()) - val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon)) - val now = nowMillis - val expireTimeMillis = pt.expire * 1000L - val expireTimeStr = - when { - pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never" - expireTimeMillis <= now -> "Expired" - else -> DateFormatter.formatRelativeTime(expireTimeMillis) - } - MarkerWithLabel(this, label, emoji).apply { - id = "${pt.id}" - title = "${pt.name} (${getUsername(waypoint.from)}$lock)" - snippet = "[$time] ${pt.description} " + getString(Res.string.expires) + ": $expireTimeStr" - position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) - if (selectedWaypointId == pt.id) { - showInfoWindow() - } - setOnLongClickListener { - showMarkerLongPressDialog(pt.id) - true - } - } - } - } - - val mapEventsReceiver = - object : MapEventsReceiver { - override fun singleTapConfirmedHelper(p: GeoPoint): Boolean { - InfoWindow.closeAllInfoWindowsOn(map) - return true - } - - override fun longPressHelper(p: GeoPoint): Boolean { - performHapticFeedback() - val enabled = isConnected && downloadRegionBoundingBox == null - - if (enabled) { - showEditWaypointDialog = - Waypoint(latitude_i = (p.latitude * 1e7).toInt(), longitude_i = (p.longitude * 1e7).toInt()) - } - return true - } - } - - fun MapView.drawOverlays() { - if (overlays.none { it is MapEventsOverlay }) { - overlays.add(0, MapEventsOverlay(mapEventsReceiver)) - } - if (myLocationOverlay != null && overlays.none { it is MyLocationNewOverlay }) { - overlays.add(myLocationOverlay) - } - if (overlays.none { it is RadiusMarkerClusterer }) { - overlays.add(nodeClusterer) - } - - addCopyright() - addScaleBarOverlay(density) - createLatLongGrid(false) - - invalidate() - } - - fun MapView.generateBoxOverlay() { - overlays.removeAll { it is Polygon } - val zoomFactor = 1.3 - zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax) - downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor) - val polygon = - Polygon().apply { - points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) } - } - overlays.add(polygon) - invalidate() - val tileCount: Int = - CacheManager(this) - .possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt()) - cacheEstimate = getString(Res.string.map_cache_tiles, tileCount) - } - - val boxOverlayListener = - object : MapListener { - override fun onScroll(event: ScrollEvent): Boolean { - if (downloadRegionBoundingBox != null) { - event.source.generateBoxOverlay() - } - return true - } - - override fun onZoom(event: ZoomEvent): Boolean = false - } - - fun startDownload() { - val boundingBox = downloadRegionBoundingBox ?: return - try { - val outputName = buildString { - append(Configuration.getInstance().osmdroidBasePath.absolutePath) - append(File.separator) - append("mainFile.sqlite") - } - val writer = SqliteArchiveTileWriter(outputName) - val cacheManager = CacheManager(map, writer) - cacheManager.downloadAreaAsync( - context, - boundingBox, - zoomLevelMin.toInt(), - zoomLevelMax.toInt(), - cacheManagerCallback( - onTaskComplete = { - scope.launch { context.showToast(Res.string.map_download_complete) } - writer.onDetach() - }, - onTaskFailed = { errors -> - scope.launch { context.showToast(Res.string.map_download_errors, errors) } - writer.onDetach() - }, - ), - ) - } catch (ex: TileSourcePolicyException) { - Logger.d { "Tile source does not allow archiving: ${ex.message}" } - } catch (ex: Exception) { - Logger.d { "Tile source exception: ${ex.message}" } - } - } - - Scaffold( - modifier = modifier, - floatingActionButton = { - DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true } - }, - ) { innerPadding -> - Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - AndroidView( - factory = { - map.apply { - setDestroyMode(false) - addMapListener(boxOverlayListener) - } - }, - modifier = Modifier.fillMaxSize(), - update = { mapView -> - with(mapView) { - updateMarkers( - onNodesChanged(nodes), - onWaypointChanged(waypoints.values, selectedWaypointId), - nodeClusterer, - ) - } - mapView.drawOverlays() - }, // Renamed map to mapView to avoid conflict - ) - if (downloadRegionBoundingBox != null) { - CacheLayout( - cacheEstimate = cacheEstimate, - onExecuteJob = { startDownload() }, - onCancelDownload = { - downloadRegionBoundingBox = null - map.overlays.removeAll { it is Polygon } - map.invalidate() - }, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } else { - MapControlsOverlay( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), - onToggleFilterMenu = { mapFilterExpanded = true }, - filterDropdownContent = { - FdroidMainMapFilterDropdown( - expanded = mapFilterExpanded, - onDismissRequest = { mapFilterExpanded = false }, - 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() - } - }, - ) - } - } - } - - if (showMapStyleDialog) { - MapStyleDialog( - selectedMapStyle = mapViewModel.mapStyleId, - onDismiss = { showMapStyleDialog = false }, - onSelectMapStyle = { - mapViewModel.mapStyleId = it - map.setTileSource(loadOnlineTileSourceBase()) - }, - ) - } - - if (showCacheManagerDialog) { - CacheManagerDialog( - onClickOption = { option -> - when (option) { - CacheManagerOption.CurrentCacheSize -> { - scope.launch { context.showToast(Res.string.calculating) } - showCurrentCacheInfo = true - } - - CacheManagerOption.DownloadRegion -> map.generateBoxOverlay() - - CacheManagerOption.ClearTiles -> showPurgeTileSourceDialog = true - - CacheManagerOption.Cancel -> Unit - } - showCacheManagerDialog = false - }, - onDismiss = { showCacheManagerDialog = false }, - ) - } - - if (showCurrentCacheInfo) { - CacheInfoDialog(mapView = map, onDismiss = { showCurrentCacheInfo = false }) - } - - if (showPurgeTileSourceDialog) { - PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false }) - } - - if (showEditWaypointDialog != null) { - EditWaypointDialog( - waypoint = showEditWaypointDialog ?: return, // Safe call - onSendClicked = { waypoint -> - Logger.d { "User clicked send waypoint ${waypoint.id}" } - showEditWaypointDialog = null - - val newId = if (waypoint.id == 0) mapViewModel.generatePacketId() else waypoint.id - val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name - val newExpire = if (waypoint.expire == 0) Int.MAX_VALUE else waypoint.expire - val newLockedTo = if (waypoint.locked_to != 0) mapViewModel.myNodeNum ?: 0 else 0 - val newIcon = if (waypoint.icon == 0) 128205 else waypoint.icon - - mapViewModel.sendWaypoint( - waypoint.copy( - id = newId, - name = newName, - expire = newExpire, - locked_to = newLockedTo, - icon = newIcon, - ), - ) - }, - onDeleteClicked = { waypoint -> - Logger.d { "User clicked delete waypoint ${waypoint.id}" } - showEditWaypointDialog = null - showDeleteMarkerDialog(waypoint) - }, - onDismissRequest = { - Logger.d { "User clicked cancel marker edit dialog" } - showEditWaypointDialog = null - }, - ) - } -} - -/** 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, -) { - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) { - DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) { - 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() }, - ) - } - 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) } - - MapsDialog(onDismiss = onDismiss) { - CustomTileSource.mTileSources.values.forEachIndexed { index, style -> - ListItem( - text = style, - trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null, - onClick = { - selected.value = index - onSelectMapStyle(index) - onDismiss() - }, - ) - } - } -} - -private enum class CacheManagerOption(val label: StringResource) { - CurrentCacheSize(label = Res.string.map_cache_size), - DownloadRegion(label = Res.string.map_download_region), - ClearTiles(label = Res.string.map_clear_tiles), - Cancel(label = Res.string.cancel), -} - -@Composable -private fun CacheManagerDialog(onClickOption: (CacheManagerOption) -> Unit, onDismiss: () -> Unit) { - MapsDialog(title = stringResource(Res.string.map_offline_manager), onDismiss = onDismiss) { - CacheManagerOption.entries.forEach { option -> - ListItem(text = stringResource(option.label), trailingIcon = null) { - onClickOption(option) - onDismiss() - } - } - } -} - -@Composable -private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) { - val (cacheCapacity, currentCacheUsage) = - remember(mapView) { - val cacheManager = CacheManager(mapView) - cacheManager.cacheCapacity() to cacheManager.currentCacheUsage() - } - - MapsDialog( - title = stringResource(Res.string.map_cache_manager), - onDismiss = onDismiss, - negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, - ) { - val capacityMb = (cacheCapacity / (1024 * 1024)).toLong() - val usageMb = (currentCacheUsage / (1024 * 1024)).toLong() - Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb)) - } -} - -@Composable -private fun PurgeTileSourceDialog(onDismiss: () -> Unit) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - val cache = SqlTileWriterExt() - - val sourceList by derivedStateOf { cache.sources.map { it.source as String } } - - val selected = remember { mutableStateListOf() } - - MapsDialog( - title = stringResource(Res.string.map_tile_source), - positiveButton = { - TextButton( - enabled = selected.isNotEmpty(), - onClick = { - selected.forEach { selectedIndex -> - val source = sourceList[selectedIndex] - scope.launch { - context.showToast( - if (cache.purgeCache(source)) { - getString(Res.string.map_purge_success, source) - } else { - getString(Res.string.map_purge_fail) - }, - ) - } - } - - onDismiss() - }, - ) { - Text(text = stringResource(Res.string.clear)) - } - }, - negativeButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.cancel)) } }, - onDismiss = onDismiss, - ) { - sourceList.forEachIndexed { index, source -> - val isSelected = selected.contains(index) - BasicListItem( - text = source, - trailingContent = { Checkbox(checked = isSelected, onCheckedChange = {}) }, - onClick = { - if (isSelected) { - selected.remove(index) - } else { - selected.add(index) - } - }, - ) {} - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MapsDialog( - title: String? = null, - onDismiss: () -> Unit, - positiveButton: (@Composable () -> Unit)? = null, - negativeButton: (@Composable () -> Unit)? = null, - content: @Composable ColumnScope.() -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.wrapContentWidth().wrapContentHeight(), - shape = MaterialTheme.shapes.large, - color = AlertDialogDefaults.containerColor, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - Column { - title?.let { - Text( - modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp), - text = it, - style = MaterialTheme.typography.titleLarge, - ) - } - - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() } - if (positiveButton != null || negativeButton != null) { - Row(Modifier.align(Alignment.End)) { - positiveButton?.invoke() - negativeButton?.invoke() - } - } - } - } - } -} - -private const val WAYPOINT_ZOOM = 15.0 diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt deleted file mode 100644 index 0dcc305195..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt +++ /dev/null @@ -1,145 +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 - -import android.graphics.Color -import android.graphics.DashPathEffect -import android.graphics.Paint -import android.graphics.Typeface -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat -import org.meshtastic.app.R -import org.meshtastic.proto.Position -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.CopyrightOverlay -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polyline -import org.osmdroid.views.overlay.ScaleBarOverlay -import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList -import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2 - -/** Adds copyright to map depending on what source is showing */ -fun MapView.addCopyright() { - if (overlays.none { it is CopyrightOverlay }) { - val copyrightNotice: String = tileProvider.tileSource.copyrightNotice ?: return - val copyrightOverlay = CopyrightOverlay(context) - copyrightOverlay.setCopyrightNotice(copyrightNotice) - overlays.add(copyrightOverlay) - } -} - -/** - * Create LatLong Grid line overlay - * - * @param enabled: turn on/off gridlines - */ -fun MapView.createLatLongGrid(enabled: Boolean) { - val latLongGridOverlay = LatLonGridlineOverlay2() - latLongGridOverlay.isEnabled = enabled - if (latLongGridOverlay.isEnabled) { - val textPaint = - Paint().apply { - textSize = 40f - color = Color.GRAY - isAntiAlias = true - isFakeBoldText = true - textAlign = Paint.Align.CENTER - } - latLongGridOverlay.textPaint = textPaint - latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT) - latLongGridOverlay.setLineWidth(3.0f) - latLongGridOverlay.setLineColor(Color.GRAY) - overlays.add(latLongGridOverlay) - } -} - -fun MapView.addScaleBarOverlay(density: Density) { - if (overlays.none { it is ScaleBarOverlay }) { - val scaleBarOverlay = - ScaleBarOverlay(this).apply { - setAlignBottom(true) - with(density) { - setScaleBarOffset(15.dp.toPx().toInt(), 40.dp.toPx().toInt()) - setTextSize(12.sp.toPx()) - } - textPaint.apply { - isAntiAlias = true - typeface = Typeface.DEFAULT_BOLD - } - } - overlays.add(scaleBarOverlay) - } -} - -fun MapView.addPolyline(density: Density, geoPoints: List, onClick: () -> Unit): Polyline { - val polyline = - Polyline(this).apply { - val borderPaint = - Paint().apply { - color = Color.BLACK - isAntiAlias = true - strokeWidth = with(density) { 10.dp.toPx() } - style = Paint.Style.STROKE - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) - } - outlinePaintLists.add(MonochromaticPaintList(borderPaint)) - val fillPaint = - Paint().apply { - color = Color.WHITE - isAntiAlias = true - strokeWidth = with(density) { 6.dp.toPx() } - style = Paint.Style.FILL_AND_STROKE - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f) - } - outlinePaintLists.add(MonochromaticPaintList(fillPaint)) - setPoints(geoPoints) - setOnClickListener { _, _, _ -> - onClick() - true - } - } - overlays.add(polyline) - - return polyline -} - -fun MapView.addPositionMarkers(positions: List, onClick: (Int) -> Unit): List { - val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation) - val markers = - positions.map { pos -> - Marker(this).apply { - icon = navIcon - rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat() - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) - position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7) - setOnMarkerClickListener { _, _ -> - onClick(pos.time) - true - } - } - } - overlays.addAll(markers) - - return markers -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt deleted file mode 100644 index eefd9df435..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ /dev/null @@ -1,67 +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 - -import androidx.lifecycle.SavedStateHandle -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.LocalConfig - -@Suppress("LongParameterList") -@KoinViewModel -class MapViewModel( - mapPrefs: MapPrefs, - packetRepository: PacketRepository, - nodeRepository: NodeRepository, - radioController: RadioController, - radioConfigRepository: RadioConfigRepository, - buildConfigProvider: BuildConfigProvider, - savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { - - private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) - val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() - - fun setWaypointId(id: Int?) { - if (_selectedWaypointId.value != id) { - _selectedWaypointId.value = id - } - } - - var mapStyleId: Int - get() = mapPrefs.mapStyle.value - set(value) { - mapPrefs.setMapStyle(value) - } - - val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) - - val config - get() = localConfig.value - - val applicationId = buildConfigProvider.applicationId -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt deleted file mode 100644 index 3ce34eb479..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt +++ /dev/null @@ -1,136 +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 - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.CustomZoomButtonsController -import org.osmdroid.views.MapView - -private const val MIN_ZOOM_LEVEL = 1.5 -private const val MAX_ZOOM_LEVEL = 20.0 -private const val DEFAULT_ZOOM_LEVEL = 15.0 - -@Suppress("MagicNumber") -@Composable -fun rememberMapViewWithLifecycle( - applicationId: String, - box: BoundingBox, - tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE, -): MapView { - val zoom = - if (box.requiredZoomLevel().isFinite()) { - (box.requiredZoomLevel() - 0.5).coerceAtLeast(MIN_ZOOM_LEVEL) - } else { - DEFAULT_ZOOM_LEVEL - } - val center = GeoPoint(box.centerLatitude, box.centerLongitude) - return rememberMapViewWithLifecycle( - applicationId = applicationId, - zoomLevel = zoom, - mapCenter = center, - tileSource = tileSource, - ) -} - -@Suppress("LongMethod") -@Composable -internal fun rememberMapViewWithLifecycle( - applicationId: String, - zoomLevel: Double = MIN_ZOOM_LEVEL, - mapCenter: GeoPoint = GeoPoint(0.0, 0.0), - tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE, -): MapView { - var savedZoom by rememberSaveable { mutableDoubleStateOf(zoomLevel) } - var savedCenter by - rememberSaveable( - stateSaver = - Saver( - save = { mapOf("latitude" to it.latitude, "longitude" to it.longitude) }, - restore = { GeoPoint(it["latitude"] ?: 0.0, it["longitude"] ?: .0) }, - ), - ) { - mutableStateOf(mapCenter) - } - - val context = LocalContext.current - val mapView = remember { - MapView(context).apply { - clipToOutline = true - - // Required to get online tiles - Configuration.getInstance().userAgentValue = applicationId - setTileSource(tileSource) - isVerticalMapRepetitionEnabled = false // disables map repetition - setMultiTouchControls(true) - val bounds = overlayManager.tilesOverlay.bounds // bounds scrollable map - setScrollableAreaLimitLatitude(bounds.actualNorth, bounds.actualSouth, 0) - // scales the map tiles to the display density of the screen - isTilesScaledToDpi = true - // sets the minimum zoom level (the furthest out you can zoom) - minZoomLevel = MIN_ZOOM_LEVEL - maxZoomLevel = MAX_ZOOM_LEVEL - // Disables default +/- button for zooming - zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT) - - controller.setZoom(savedZoom) - controller.setCenter(savedCenter) - } - } - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_PAUSE -> { - mapView.onPause() - } - - Lifecycle.Event.ON_RESUME -> { - mapView.onResume() - } - - Lifecycle.Event.ON_STOP -> { - savedCenter = mapView.projection.currentCenter - savedZoom = mapView.zoomLevelDouble - } - - else -> {} - } - } - - lifecycle.addObserver(observer) - - onDispose { lifecycle.removeObserver(observer) } - } - return mapView -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt deleted file mode 100644 index 9bd5cac52e..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt +++ /dev/null @@ -1,111 +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 - -import android.database.Cursor -import org.meshtastic.core.common.util.nowMillis -import org.osmdroid.tileprovider.modules.DatabaseFileArchive -import org.osmdroid.tileprovider.modules.SqlTileWriter - -/** - * Extended the sqlite tile writer to have some additional query functions. A this point it's unclear if there is a need - * to put these with the osmdroid-android library, thus they were put here as more of an example. - * - * created on 12/21/2016. - * - * @author Alex O'Ree - * @since 5.6.2 - */ -class SqlTileWriterExt : SqlTileWriter() { - fun select(rows: Int, offset: Int): Cursor? = this.db?.rawQuery( - "select " + - DatabaseFileArchive.COLUMN_KEY + - "," + - COLUMN_EXPIRES + - "," + - DatabaseFileArchive.COLUMN_PROVIDER + - " from " + - DatabaseFileArchive.TABLE + - " limit ? offset ?", - arrayOf(rows.toString() + "", offset.toString() + ""), - ) - - /** - * gets all the tiles sources that we have tiles for in the cache database and their counts - * - * @return - */ - val sources: List - get() { - val db = db - val ret: MutableList = ArrayList() - if (db == null) { - return ret - } - var cur: Cursor? = null - try { - cur = - db.rawQuery( - "select " + - DatabaseFileArchive.COLUMN_PROVIDER + - ",count(*) " + - ",min(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - ",max(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - ",sum(length(" + - DatabaseFileArchive.COLUMN_TILE + - ")) " + - "from " + - DatabaseFileArchive.TABLE + - " " + - "group by " + - DatabaseFileArchive.COLUMN_PROVIDER, - null, - ) - while (cur.moveToNext()) { - val c = SourceCount() - c.source = cur.getString(0) - c.rowCount = cur.getLong(1) - c.sizeMin = cur.getLong(2) - c.sizeMax = cur.getLong(3) - c.sizeTotal = cur.getLong(4) - c.sizeAvg = c.sizeTotal / c.rowCount - ret.add(c) - } - } catch (e: Exception) { - catchException(e) - } finally { - cur?.close() - } - return ret - } - - val rowCountExpired: Long - get() = getRowCount("$COLUMN_EXPIRES. - */ -package org.meshtastic.app.map.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.map_select_download_region -import org.meshtastic.core.resources.map_start_download -import org.meshtastic.core.resources.map_tile_download_estimate - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun CacheLayout( - cacheEstimate: String, - onExecuteJob: () -> Unit, - onCancelDownload: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .fillMaxWidth() - .wrapContentHeight() - .background(color = MaterialTheme.colorScheme.background) - .padding(8.dp), - ) { - Text( - text = stringResource(Res.string.map_select_download_region), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineSmall, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(Res.string.map_tile_download_estimate) + " " + cacheEstimate, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - ) - - FlowRow( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(space = 8.dp), - ) { - Button(onClick = onCancelDownload, modifier = Modifier.weight(1f)) { - Text(text = stringResource(Res.string.cancel), color = MaterialTheme.colorScheme.onPrimary) - } - Button(onClick = onExecuteJob, modifier = Modifier.weight(1f)) { - Text(text = stringResource(Res.string.map_start_download), color = MaterialTheme.colorScheme.onPrimary) - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun CacheLayoutPreview() { - CacheLayout(cacheEstimate = "100 tiles", onExecuteJob = {}, onCancelDownload = {}) -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt deleted file mode 100644 index 0cb1ec5e03..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt +++ /dev/null @@ -1,59 +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.component - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.map_download_region -import org.meshtastic.core.ui.icon.Download -import org.meshtastic.core.ui.icon.MeshtasticIcons - -@Composable -fun DownloadButton(enabled: Boolean, onClick: () -> Unit) { - AnimatedVisibility( - visible = enabled, - enter = - slideInHorizontally( - initialOffsetX = { it }, - animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing), - ), - exit = - slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing), - ), - ) { - FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) { - Icon( - imageVector = MeshtasticIcons.Download, - contentDescription = stringResource(Res.string.map_download_region), - modifier = Modifier.scale(1.25f), - ) - } - } -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt deleted file mode 100644 index f013a1253b..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ /dev/null @@ -1,357 +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.component - -import android.app.DatePickerDialog -import android.widget.DatePicker -import android.widget.TimePicker -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.Month -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.common.util.systemTimeZone -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.date -import org.meshtastic.core.resources.delete -import org.meshtastic.core.resources.description -import org.meshtastic.core.resources.expires -import org.meshtastic.core.resources.locked -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.send -import org.meshtastic.core.resources.time -import org.meshtastic.core.resources.waypoint_edit -import org.meshtastic.core.resources.waypoint_new -import org.meshtastic.core.ui.component.EditTextPreference -import org.meshtastic.core.ui.emoji.EmojiPickerDialog -import org.meshtastic.core.ui.icon.CalendarMonth -import org.meshtastic.core.ui.icon.Lock -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.theme.AppTheme -import org.meshtastic.proto.Waypoint -import kotlin.time.Duration.Companion.hours -import kotlin.time.Instant - -@Suppress("LongMethod", "CyclomaticComplexMethod") -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun EditWaypointDialog( - waypoint: Waypoint, - onSendClicked: (Waypoint) -> Unit, - onDeleteClicked: (Waypoint) -> Unit, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, -) { - var waypointInput by remember { mutableStateOf(waypoint) } - val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit - - @Suppress("MagicNumber") - val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon - var showEmojiPickerView by remember { mutableStateOf(false) } - - // Get current context for dialogs - val context = LocalContext.current - val tz = systemTimeZone - - // Determine locale-specific date format - val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } - // Check if 24-hour format is preferred - val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) } - val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) } - - val currentInstant = - remember(waypointInput.expire) { - val expire = waypointInput.expire - if (expire != 0 && expire != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(expire.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - - // State to hold selected date and time - var selectedDate by - remember(currentInstant) { - mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds())) - } else { - "" - }, - ) - } - var selectedTime by - remember(currentInstant) { - mutableStateOf( - if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) { - timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds())) - } else { - "" - }, - ) - } - - if (!showEmojiPickerView) { - AlertDialog( - onDismissRequest = onDismissRequest, - shape = RoundedCornerShape(16.dp), - text = { - Column(modifier = modifier.fillMaxWidth()) { - Text( - text = stringResource(title), - style = - MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - ), - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - ) - EditTextPreference( - title = stringResource(Res.string.name), - value = waypointInput.name, - maxSize = 29, - enabled = true, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {}), - onValueChanged = { waypointInput = waypointInput.copy(name = it) }, - trailingIcon = { - IconButton(onClick = { showEmojiPickerView = true }) { - Text( - text = String(Character.toChars(emoji)), - modifier = - Modifier.background(MaterialTheme.colorScheme.background, CircleShape) - .padding(4.dp), - fontSize = 24.sp, - color = Color.Unspecified.copy(alpha = 1f), - ) - } - }, - ) - EditTextPreference( - title = stringResource(Res.string.description), - value = waypointInput.description, - maxSize = 99, - enabled = true, - isError = false, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {}), - onValueChanged = { waypointInput = waypointInput.copy(description = it) }, - ) - Row( - modifier = Modifier.fillMaxWidth().size(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - imageVector = MeshtasticIcons.Lock, - contentDescription = stringResource(Res.string.locked), - ) - Text(stringResource(Res.string.locked)) - Switch( - modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), - checked = waypointInput.locked_to != 0, - onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) }, - ) - } - - val ldt = currentInstant.toLocalDateTime(tz) - val datePickerDialog = - DatePickerDialog( - context, - { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int -> - val newLdt = - LocalDateTime( - year = selectedYear, - month = Month(selectedMonth + 1), - day = selectedDay, - hour = ldt.hour, - minute = ldt.minute, - second = ldt.second, - nanosecond = ldt.nanosecond, - ) - waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.year, - ldt.month.ordinal, - ldt.day, - ) - - val timePickerDialog = - android.app.TimePickerDialog( - context, - { _: TimePicker, selectedHour: Int, selectedMinute: Int -> - val newLdt = - LocalDateTime( - year = ldt.year, - month = ldt.month, - day = ldt.day, - hour = selectedHour, - minute = selectedMinute, - second = ldt.second, - nanosecond = ldt.nanosecond, - ) - waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.hour, - ldt.minute, - is24Hour, - ) - - Row( - modifier = Modifier.fillMaxWidth().size(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - imageVector = MeshtasticIcons.CalendarMonth, - contentDescription = stringResource(Res.string.expires), - ) - Text(stringResource(Res.string.expires)) - Switch( - modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End), - checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0, - onCheckedChange = { isChecked -> - if (isChecked) { - waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt()) - } else { - waypointInput = waypointInput.copy(expire = Int.MAX_VALUE) - } - }, - ) - } - - if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedDate, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedTime, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) - } - } - } - } - }, - confirmButton = { - FlowRow( - modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.Center, - ) { - TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) { - Text(stringResource(Res.string.cancel)) - } - if (waypoint.id != 0) { - Button( - modifier = modifier.weight(1f), - onClick = { onDeleteClicked(waypointInput) }, - enabled = !(waypointInput.name.isNullOrEmpty()), - ) { - Text(stringResource(Res.string.delete)) - } - } - Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) { - Text(stringResource(Res.string.send)) - } - } - }, - ) - } else { - EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { - showEmojiPickerView = false - waypointInput = waypointInput.copy(icon = it.codePointAt(0)) - } - } -} - -@Preview(showBackground = true) -@Composable -@Suppress("MagicNumber") -private fun EditWaypointFormPreview() { - AppTheme { - EditWaypointDialog( - waypoint = - Waypoint( - id = 123, - name = "Test 123", - description = "This is only a test", - icon = 128169, - expire = (nowSeconds.toInt() + 8 * 3600), - ), - onSendClicked = {}, - onDeleteClicked = {}, - onDismissRequest = {}, - ) - } -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt deleted file mode 100644 index 722111ab6a..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt +++ /dev/null @@ -1,150 +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.model - -import org.osmdroid.tileprovider.tilesource.ITileSource -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex - -class CustomTileSource { - - companion object { - val OPENWEATHER_RADAR = - OnlineTileSourceAuth( - "Open Weather Map", - 1, - 22, - 256, - ".png", - arrayOf("https://tile.openweathermap.org/map/"), - "Openweathermap", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - "precipitation", - "", - ) - private val ESRI_IMAGERY = - object : - OnlineTileSourceBase( - "ESRI World Overview", - 1, - 20, - 256, - ".jpg", - arrayOf("https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"), - "Esri, Maxar, Earthstar Geographics, and the GIS User Community", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - - private val ESRI_WORLD_TOPO = - object : - OnlineTileSourceBase( - "ESRI World TOPO", - 1, - 20, - 256, - ".jpg", - arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"), - "Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ", - TileSourcePolicy( - 4, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), - ) { - override fun getTileURLString(pMapTileIndex: Long): String = baseUrl + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex) + - "/" + - MapTileIndex.getX(pMapTileIndex) + - mImageFilenameEnding - ) - } - - /** WMS TILE SERVER More research is required to get this to function correctly with overlays */ - val NOAA_RADAR_WMS = - NOAAWmsTileSource( - "Recent Weather Radar", - arrayOf( - "https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/" + - "radar_meteo_imagery_nexrad_time/MapServer/WmsServer?", - ), - "1", - "1.1.0", - "", - "EPSG%3A3857", - "", - "image/png", - ) - - /** =============================================================================================== */ - private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK - private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO - private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo - private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT - val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE - - /** Source for each available [ITileSource] and their display names. */ - val mTileSources: Map = - mapOf( - MAPNIK to "OpenStreetMap", - USGS_TOPO to "USGS TOPO", - OPEN_TOPO to "Open TOPO", - ESRI_WORLD_TOPO to "ESRI World TOPO", - USGS_SAT to "USGS Satellite", - ESRI_IMAGERY to "ESRI World Overview", - ) - - fun getTileSource(index: Int): ITileSource = mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE - - fun getTileSource(aName: String): ITileSource { - for (tileSource: ITileSource in mTileSources.keys) { - if (tileSource.name().equals(aName)) { - return tileSource - } - } - throw IllegalArgumentException("No such tile source: $aName") - } - } -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt deleted file mode 100644 index 0b16e13188..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt +++ /dev/null @@ -1,138 +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.model - -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.RectF -import android.view.MotionEvent -import org.meshtastic.app.map.dpToPx -import org.meshtastic.app.map.spToPx -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Polygon - -class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) { - - companion object { - private const val LABEL_CORNER_RADIUS_DP = 4f - private const val LABEL_Y_OFFSET_DP = 34f - private const val FONT_SIZE_SP = 14f - private const val EMOJI_FONT_SIZE_SP = 20f - } - - private val labelYOffsetPx by lazy { mapView?.context?.dpToPx(LABEL_Y_OFFSET_DP) ?: 100 } - - private val labelCornerRadiusPx by lazy { mapView?.context?.dpToPx(LABEL_CORNER_RADIUS_DP) ?: 12 } - - private var nodeColor: Int = Color.GRAY - - fun setNodeColors(colors: Pair) { - nodeColor = colors.second - } - - private var precisionBits: Int? = null - - fun setPrecisionBits(bits: Int) { - precisionBits = bits - } - - @Suppress("MagicNumber") - private fun getPrecisionMeters(): Double? = when (precisionBits) { - 10 -> 23345.484932 - 11 -> 11672.7369 - 12 -> 5836.36288 - 13 -> 2918.175876 - 14 -> 1459.0823719999053 - 15 -> 729.53562 - 16 -> 364.7622 - 17 -> 182.375556 - 18 -> 91.182212 - 19 -> 45.58554 - else -> null - } - - private var onLongClickListener: (() -> Boolean)? = null - - fun setOnLongClickListener(listener: () -> Boolean) { - onLongClickListener = listener - } - - private val mLabel = label - private val mEmoji = emoji - private val textPaint = - Paint().apply { - textSize = mapView?.context?.spToPx(FONT_SIZE_SP)?.toFloat() ?: 40f - color = Color.DKGRAY - isAntiAlias = true - isFakeBoldText = true - textAlign = Paint.Align.CENTER - } - private val emojiPaint = - Paint().apply { - textSize = mapView?.context?.spToPx(EMOJI_FONT_SIZE_SP)?.toFloat() ?: 80f - isAntiAlias = true - textAlign = Paint.Align.CENTER - } - - private val bgPaint = Paint().apply { color = Color.WHITE } - - private fun getTextBackgroundSize(text: String, x: Float, y: Float): RectF { - val fontMetrics = textPaint.fontMetrics - val halfTextLength = textPaint.measureText(text) / 2 + 3 - return RectF((x - halfTextLength), (y + fontMetrics.top), (x + halfTextLength), (y + fontMetrics.bottom)) - } - - override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean { - val touched = hitTest(event, mapView) - if (touched && this.id != null) { - return onLongClickListener?.invoke() ?: super.onLongPress(event, mapView) - } - return super.onLongPress(event, mapView) - } - - @Suppress("MagicNumber") - override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) { - super.draw(c, osmv, false) - val p = mPositionPixels - val bgRect = getTextBackgroundSize(mLabel, p.x.toFloat(), (p.y - labelYOffsetPx.toFloat())) - bgRect.inset(-8F, -2F) - - if (mLabel.isNotEmpty()) { - c.drawRoundRect(bgRect, labelCornerRadiusPx.toFloat(), labelCornerRadiusPx.toFloat(), bgPaint) - c.drawText(mLabel, (p.x - 0F), (p.y - labelYOffsetPx.toFloat()), textPaint) - } - mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) } - - getPrecisionMeters()?.let { radius -> - val polygon = - Polygon(osmv).apply { - points = Polygon.pointsAsCircle(position, radius) - fillPaint.apply { - color = nodeColor - alpha = 48 - } - outlinePaint.apply { - color = nodeColor - alpha = 64 - } - } - polygon.draw(c, osmv, false) - } - } -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt deleted file mode 100644 index 55e95e2b68..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt +++ /dev/null @@ -1,160 +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.model - -import android.content.res.Resources -import co.touchlab.kermit.Logger -import org.osmdroid.api.IMapView -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex -import kotlin.math.atan -import kotlin.math.pow -import kotlin.math.sinh - -open class NOAAWmsTileSource( - aName: String, - aBaseUrl: Array, - layername: String, - version: String, - time: String?, - srs: String, - style: String?, - format: String, -) : OnlineTileSourceBase( - aName, - 0, - 5, - 256, - "png", - aBaseUrl, - "", - TileSourcePolicy( - 2, - TileSourcePolicy.FLAG_NO_BULK or - TileSourcePolicy.FLAG_NO_PREVENTIVE or - TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or - TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED, - ), -) { - - // array indexes for array to hold bounding boxes. - private val minX = 0 - private val maxX = 1 - private val minY = 2 - private val maxY = 3 - - // Web Mercator n/w corner of the map. - private val tileOrigin = doubleArrayOf(-20037508.34789244, 20037508.34789244) - - // array indexes for that data - private val origX = 0 - private val origY = 1 // " - - // Size of square world map in meters, using WebMerc projection. - private val mapSize = 20037508.34789244 * 2 - private var layer = "" - private var version = "1.1.0" - private var srs = "EPSG%3A3857" // used by geo server - private var format = "" - private var time = "" - private var style: String? = null - private var forceHttps = false - private var forceHttp = false - - init { - Logger.withTag(IMapView.LOGTAG).i { "WMS support is BETA. Please report any issues" } - layer = layername - this.version = version - this.srs = srs - this.style = style - this.format = format - if (time != null) this.time = time - } - - private fun tile2lon(x: Int, z: Int): Double = x / 2.0.pow(z.toDouble()) * 360.0 - 180 - - private fun tile2lat(y: Int, z: Int): Double { - val n = Math.PI - 2.0 * Math.PI * y / 2.0.pow(z.toDouble()) - return Math.toDegrees(atan(sinh(n))) - } - - // Return a web Mercator bounding box given tile x/y indexes and a zoom - // level. - private fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray { - val tileSize = mapSize / 2.0.pow(zoom.toDouble()) - val minx = tileOrigin[origX] + x * tileSize - val maxx = tileOrigin[origX] + (x + 1) * tileSize - val miny = tileOrigin[origY] - (y + 1) * tileSize - val maxy = tileOrigin[origY] - y * tileSize - val bbox = DoubleArray(4) - bbox[minX] = minx - bbox[minY] = miny - bbox[maxX] = maxx - bbox[maxY] = maxy - return bbox - } - - fun isForceHttps(): Boolean = forceHttps - - fun setForceHttps(forceHttps: Boolean) { - this.forceHttps = forceHttps - } - - fun isForceHttp(): Boolean = forceHttp - - fun setForceHttp(forceHttp: Boolean) { - this.forceHttp = forceHttp - } - - override fun getTileURLString(pMapTileIndex: Long): String? { - var baseUrl = baseUrl - if (forceHttps) baseUrl = baseUrl.replace("http://", "https://") - if (forceHttp) baseUrl = baseUrl.replace("https://", "http://") - val sb = StringBuilder(baseUrl) - if (!baseUrl.endsWith("&")) sb.append("service=WMS") - sb.append("&request=GetMap") - sb.append("&version=").append(version) - sb.append("&layers=").append(layer) - if (style != null) sb.append("&styles=").append(style) - sb.append("&format=").append(format) - sb.append("&transparent=true") - sb.append("&height=").append(Resources.getSystem().displayMetrics.heightPixels) - sb.append("&width=").append(Resources.getSystem().displayMetrics.widthPixels) - sb.append("&srs=").append(srs) - sb.append("&size=").append(getSize()) - sb.append("&bbox=") - val bbox = - getBoundingBox( - MapTileIndex.getX(pMapTileIndex), - MapTileIndex.getY(pMapTileIndex), - MapTileIndex.getZoom(pMapTileIndex), - ) - sb.append(bbox[minX]).append(",") - sb.append(bbox[minY]).append(",") - sb.append(bbox[maxX]).append(",") - sb.append(bbox[maxY]) - Logger.withTag(IMapView.LOGTAG).i { sb.toString() } - return sb.toString() - } - - private fun getSize(): String { - val height = Resources.getSystem().displayMetrics.heightPixels - val width = Resources.getSystem().displayMetrics.widthPixels - return "$width,$height" - } -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt deleted file mode 100644 index 23abe72697..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt +++ /dev/null @@ -1,65 +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.model - -import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase -import org.osmdroid.tileprovider.tilesource.TileSourcePolicy -import org.osmdroid.util.MapTileIndex - -@Suppress("LongParameterList") -open class OnlineTileSourceAuth( - name: String, - zoomLevel: Int, - zoomMaxLevel: Int, - tileSizePixels: Int, - imageFileNameEnding: String, - baseUrl: Array, - pCopyright: String, - tileSourcePolicy: TileSourcePolicy, - layerName: String?, - apiKey: String, -) : OnlineTileSourceBase( - name, - zoomLevel, - zoomMaxLevel, - tileSizePixels, - imageFileNameEnding, - baseUrl, - pCopyright, - tileSourcePolicy, -) { - private var layerName = "" - private var apiKey = "" - - init { - if (layerName != null) { - this.layerName = layerName - } - this.apiKey = apiKey - } - - override fun getTileURLString(pMapTileIndex: Long): String = "$baseUrl$layerName/" + - ( - MapTileIndex.getZoom(pMapTileIndex).toString() + - "/" + - MapTileIndex.getX(pMapTileIndex).toString() + - "/" + - MapTileIndex.getY(pMapTileIndex).toString() - ) + - mImageFilenameEnding + - "?appId=$apiKey" -} 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 949655f292..0000000000 --- a/androidApp/src/fdroid/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.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 -> - NodeTrackOsmMap( - positions = positions, - applicationId = nodeMapViewModel.applicationId, - mapStyleId = nodeMapViewModel.mapStyleId, - modifier = Modifier.fillMaxSize().padding(paddingValues), - ) - } -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt deleted file mode 100644 index 77b595d88e..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ /dev/null @@ -1,50 +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.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]). - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - */ -@Composable -fun NodeTrackMap( - destNum: Int, - positions: List, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { - val vm = koinViewModel() - vm.setDestNum(destNum) - NodeTrackOsmMap( - positions = positions, - applicationId = vm.applicationId, - mapStyleId = vm.mapStyleId, - modifier = modifier, - selectedPositionTime = selectedPositionTime, - onPositionSelected = onPositionSelected, - ) -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt deleted file mode 100644 index a6aec4c2dc..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt +++ /dev/null @@ -1,162 +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.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.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.feature.map.component.MapControlsOverlay -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.feature.map.component.MapControlsOverlay] with a track time filter slider - * so users can adjust the time range directly from the map. - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - * - * Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or - * location tracking. It is designed to be embedded inside the position-log adaptive layout. - */ -@Composable -fun NodeTrackOsmMap( - positions: List, - applicationId: String, - mapStyleId: Int, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, - 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) { time -> onPositionSelected?.invoke(time) } - // Center on selected position - if (selectedPositionTime != null) { - val selected = filteredPositions.find { it.time == selectedPositionTime } - if (selected != null) { - val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D) - map.controller.animateTo(point) - } - } - }, - ) - - // 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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt deleted file mode 100644 index fcf1d47e97..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt +++ /dev/null @@ -1,41 +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.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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt deleted file mode 100644 index 95e9a55682..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt +++ /dev/null @@ -1,289 +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 . - */ -@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/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt deleted file mode 100644 index 00d3cf9c76..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt +++ /dev/null @@ -1,64 +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.node.component - -import android.view.ViewGroup -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import org.meshtastic.core.model.Node -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Marker - -@Composable -fun InlineMap(node: Node, modifier: Modifier = Modifier) { - val context = androidx.compose.ui.platform.LocalContext.current - - val map = remember { - MapView(context).apply { - layoutParams = - ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - - // Default osmdroid tile source. - setTileSource(TileSourceFactory.MAPNIK) - setMultiTouchControls(false) - - controller.setZoom(15.0) - } - } - - LaunchedEffect(node.num) { - val point = GeoPoint(node.latitude, node.longitude) - - map.overlays.clear() - - val marker = - Marker(map).apply { - position = point - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - } - map.overlays.add(marker) - - map.controller.animateTo(point) - } - - AndroidView(factory = { map }, modifier = modifier) -} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt deleted file mode 100644 index 6fc3ea54e7..0000000000 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt +++ /dev/null @@ -1,28 +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.node.metrics - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.dp -import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets - -fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets( - overlayAlignment = Alignment.BottomEnd, - overlayPadding = PaddingValues(end = 16.dp, bottom = 16.dp), - contentHorizontalAlignment = Alignment.End, -) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt index 20fe0bff6d..c4692fdb4e 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt @@ -17,7 +17,6 @@ package org.meshtastic.app.di import org.koin.core.annotation.Module -import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule -@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class, GoogleAiModule::class]) +@Module(includes = [GoogleNetworkModule::class, GoogleAiModule::class]) class FlavorModule diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt deleted file mode 100644 index 8a441fa70a..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt +++ /dev/null @@ -1,21 +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 - -import org.meshtastic.core.ui.util.MapViewProvider - -fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider() diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt deleted file mode 100644 index 940c4ab5a0..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt +++ /dev/null @@ -1,39 +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 - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -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, 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, - ) - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt deleted file mode 100644 index e15d5b5499..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt +++ /dev/null @@ -1,139 +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 - -import android.Manifest -import android.app.Activity -import android.content.ActivityNotFoundException -import android.content.pm.PackageManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts -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.platform.LocalContext -import androidx.core.content.ContextCompat -import co.touchlab.kermit.Logger -import com.google.android.gms.common.api.ResolvableApiException -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationServices -import com.google.android.gms.location.LocationSettingsRequest -import com.google.android.gms.location.Priority - -private const val INTERVAL_MILLIS = 10000L - -@Suppress("LongMethod") -@Composable -fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) { - val context = LocalContext.current - var localHasPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED, - ) - } - - val requestLocationPermissionLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted -> - localHasPermission = isGranted - // Defer to the LaunchedEffect(localHasPermission) to check settings before confirming via - // onPermissionResult - // if permission is granted. If not granted, immediately report false. - if (!isGranted) { - onPermissionResult(false) - } - } - - val locationSettingsLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - Logger.d { "Location settings changed by user." } - // User has enabled location services or improved accuracy. - onPermissionResult(true) // Settings are now adequate, and permission was already granted. - } else { - Logger.d { "Location settings change cancelled by user." } - // User chose not to change settings. The permission itself is still granted, - // but the experience might be degraded. For the purpose of enabling map features, - // we consider this as success if the core permission is there. - // If stricter handling is needed (e.g., block feature if settings not optimal), - // this logic might change. - onPermissionResult(localHasPermission) - } - } - - LaunchedEffect(Unit) { - // Initial permission check - when (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) { - PackageManager.PERMISSION_GRANTED -> { - if (!localHasPermission) { - localHasPermission = true - } - // If permission is already granted, proceed to check location settings. - // The LaunchedEffect(localHasPermission) will handle this. - // No need to call onPermissionResult(true) here yet, let settings check complete. - } - - else -> { - // Request permission if not granted. The launcher's callback will update localHasPermission. - requestLocationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } - } - } - - LaunchedEffect(localHasPermission) { - // Handles logic after permission status is known/updated - if (localHasPermission) { - // Permission is granted, now check location settings - val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, INTERVAL_MILLIS).build() - - val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest) - - val client = LocationServices.getSettingsClient(context) - val task = client.checkLocationSettings(builder.build()) - - task.addOnSuccessListener { - Logger.d { "Location settings are satisfied." } - onPermissionResult(true) // Permission granted and settings are good - } - - task.addOnFailureListener { exception -> - if (exception is ResolvableApiException) { - try { - val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build() - locationSettingsLauncher.launch(intentSenderRequest) - // Result of this launch will be handled by locationSettingsLauncher's callback - } catch (sendEx: ActivityNotFoundException) { - Logger.d { "Error launching location settings resolution ${sendEx.message}." } - onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed. - } - } else { - Logger.d { "Location settings are not satisfiable.${exception.message}" } - onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed. - } - } - } else { - // If permission is not granted, report false. - // This case is primarily handled by the requestLocationPermissionLauncher's callback - // if the initial state was denied, or if user denies it. - onPermissionResult(false) - } - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt deleted file mode 100644 index c8102ea61b..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt +++ /dev/null @@ -1,65 +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 - -import android.database.sqlite.SQLiteDatabase -import com.google.android.gms.maps.model.Tile -import com.google.android.gms.maps.model.TileProvider -import java.io.File - -class MBTilesProvider(private val file: File) : - TileProvider, - AutoCloseable { - private var database: SQLiteDatabase? = null - - init { - openDatabase() - } - - private fun openDatabase() { - if (database == null && file.exists()) { - database = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY) - } - } - - override fun getTile(x: Int, y: Int, zoom: Int): Tile? { - val db = database ?: return null - - var tile: Tile? = null - // Convert Google Maps y coordinate to standard TMS y coordinate - val tmsY = (1 shl zoom) - 1 - y - - val cursor = - db.rawQuery( - "SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?", - arrayOf(zoom.toString(), x.toString(), tmsY.toString()), - ) - - if (cursor.moveToFirst()) { - val tileData = cursor.getBlob(0) - tile = Tile(256, 256, tileData) - } - cursor.close() - - return tile ?: TileProvider.NO_TILE - } - - override fun close() { - database?.close() - database = null - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt deleted file mode 100644 index 40f2756d9d..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ /dev/null @@ -1,1126 +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 . - */ -@file:Suppress("MagicNumber") - -package org.meshtastic.app.map - -import android.Manifest -import android.app.Activity -import android.content.Intent -import android.net.Uri -import android.view.WindowManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -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.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import co.touchlab.kermit.Logger -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.google.android.gms.location.LocationCallback -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationResult -import com.google.android.gms.location.LocationServices -import com.google.android.gms.location.Priority -import com.google.android.gms.maps.CameraUpdateFactory -import com.google.android.gms.maps.model.CameraPosition -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 -import com.google.maps.android.compose.MapProperties -import com.google.maps.android.compose.MapType -import com.google.maps.android.compose.MapUiSettings -import com.google.maps.android.compose.MapsComposeExperimentalApi -import com.google.maps.android.compose.Marker -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 -import org.koin.compose.viewmodel.koinViewModel -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.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.component.rememberNodeChipDescriptor -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 -import org.meshtastic.core.model.util.toString -import org.meshtastic.core.resources.Res -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.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.component.MapButton -import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.feature.map.tracerouteNodeSelection -import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.Position -import org.meshtastic.proto.Waypoint -import kotlin.math.abs -import kotlin.math.max - -// 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, - val selectedPositionTime: Int? = null, - val onPositionSelected: ((Int) -> Unit)? = null, - ) : 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 - -@Suppress("CyclomaticComplexMethod", "LongMethod") -@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) -@Composable -fun MapView( - modifier: Modifier = Modifier, - mapViewModel: MapViewModel = koinViewModel(), - navigateToNodeDetails: (Int) -> Unit = {}, - mode: GoogleMapMode = GoogleMapMode.Main, -) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle() - - // --- Location permissions --- - val locationPermissionsState = - rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) - var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } - - // --- Location tracking --- - var isLocationTrackingEnabled by remember { mutableStateOf(false) } - var followPhoneBearing by remember { mutableStateOf(false) } - - LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { - isLocationTrackingEnabled = true - triggerLocationToggleAfterPermission = false - } - } - - // --- File picker for map layers (Main mode) --- - val filePickerLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - result.data?.data?.let { uri -> - val fileName = uri.getFileName(context) - mapViewModel.addMapLayer(uri, fileName) - } - } - } - - // --- UI state --- - var mapFilterMenuExpanded by remember { mutableStateOf(false) } - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() - var editingWaypoint by remember { mutableStateOf(null) } - - val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle() - val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle() - - var mapTypeMenuExpanded by remember { mutableStateOf(false) } - var showCustomTileManagerSheet by remember { mutableStateOf(false) } - - // --- Camera --- - // Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering. - val cameraPositionState = - if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState() - - if (mode is GoogleMapMode.Main) { - LaunchedEffect(cameraPositionState.isMoving) { - if (!cameraPositionState.isMoving) { - mapViewModel.saveCameraPosition(cameraPositionState.position) - } - } - } - - // --- FusedLocation --- - val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } - val locationCallback = remember { - object : LocationCallback() { - override fun onLocationResult(locationResult: LocationResult) { - if (isLocationTrackingEnabled) { - locationResult.lastLocation?.let { location -> - val latLng = LatLng(location.latitude, location.longitude) - val cameraUpdate = - if (followPhoneBearing) { - val bearing = - if (location.hasBearing()) { - location.bearing - } else { - cameraPositionState.position.bearing - } - CameraUpdateFactory.newCameraPosition( - CameraPosition.Builder() - .target(latLng) - .zoom(cameraPositionState.position.zoom) - .bearing(bearing) - .build(), - ) - } else { - CameraUpdateFactory.newLatLngZoom(latLng, cameraPositionState.position.zoom) - } - coroutineScope.launch { - try { - cameraPositionState.animate(cameraUpdate) - } catch (e: IllegalStateException) { - Logger.d { "Error animating camera to location: ${e.message}" } - } - } - } - } - } - } - } - - 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) - Logger.d { "Started location tracking" } - } catch (e: SecurityException) { - Logger.d { "Location permission not available: ${e.message}" } - isLocationTrackingEnabled = false - } - } else { - fusedLocationClient.removeLocationUpdates(locationCallback) - Logger.d { "Stopped location tracking" } - } - } - - 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 filteredNodes = - allNodes - .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num } - .filter { node -> - mapFilterState.lastHeardFilter.seconds == 0L || - (nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds || - node.num == ourNodeInfo?.num - } - - val myNodeNum = mapViewModel.myNodeNum - val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() - val theme by mapViewModel.theme.collectAsStateWithLifecycle() - val dark = - when (theme) { - AppCompatDelegate.MODE_NIGHT_YES -> true - AppCompatDelegate.MODE_NIGHT_NO -> false - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme() - else -> isSystemInDarkTheme() - } - 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() - } - - // 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 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) { - when { - tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints - tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints - else -> emptyList() - } - } - val tracerouteForwardOffsetPoints = - remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { - offsetPolyline(tracerouteForwardPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, 1.0) - } - val tracerouteReturnOffsetPoints = - remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) { - offsetPolyline(tracerouteReturnPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, -1.0) - } - - // 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}" } - } - } - - // Animate to selected position marker when card is tapped in the list - LaunchedEffect(mode.selectedPositionTime) { - val selectedTime = mode.selectedPositionTime ?: return@LaunchedEffect - val selectedPos = sortedTrackPositions.find { it.time == selectedTime } ?: return@LaunchedEffect - try { - cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng())) - } catch (e: IllegalStateException) { - Logger.d { "Error animating to selected position: ${e.message}" } - } - } - } - - if (mode is GoogleMapMode.Traceroute) { - 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 = { - val intent = - Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - val mimeTypes = - arrayOf( - "application/vnd.google-earth.kml+xml", - "application/vnd.google-earth.kmz", - "application/vnd.geo+json", - "application/geo+json", - "application/json", - ) - putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) - } - filePickerLauncher.launch(intent) - } - val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) } - val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) } - - 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) - } - } - - // --- Main UI --- - val isMainMode = mode is GoogleMapMode.Main - - Box(modifier = modifier) { - GoogleMap( - mapColorScheme = mapColorScheme, - modifier = Modifier.fillMaxSize(), - cameraPositionState = cameraPositionState, - uiSettings = - MapUiSettings( - zoomControlsEnabled = true, - mapToolbarEnabled = isMainMode, - compassEnabled = false, - myLocationButtonEnabled = false, - rotationGesturesEnabled = true, - scrollGesturesEnabled = true, - tiltGesturesEnabled = isMainMode, - zoomGesturesEnabled = true, - ), - properties = - MapProperties( - mapType = effectiveGoogleMapType, - isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, - ), - onMapLongClick = { latLng -> - if (isMainMode && isConnected) { - editingWaypoint = - Waypoint( - latitude_i = (latLng.latitude / DEG_D).toInt(), - longitude_i = (latLng.longitude / DEG_D).toInt(), - ) - } - }, - ) { - // Custom tile overlay (all modes) - key(currentCustomTileProviderUrl) { - currentCustomTileProviderUrl?.let { url -> - val config = - mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find { - it.urlTemplate == url || it.localUri == url - } - mapViewModel.getTileProvider(config)?.let { tileProvider -> - TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f) - } - } - } - - 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, - selectedPositionTime = mode.selectedPositionTime, - onPositionSelected = mode.onPositionSelected, - ) - } - } - - is GoogleMapMode.Traceroute -> - TracerouteMapContent( - forwardOffsetPoints = tracerouteForwardOffsetPoints, - returnOffsetPoints = tracerouteReturnOffsetPoints, - forwardPointCount = tracerouteForwardPoints.size, - returnPointCount = tracerouteReturnPoints.size, - displayNodes = tracerouteDisplayNodes, - ) - } - } - - // Scale bar - ScaleBar( - cameraPositionState = cameraPositionState, - modifier = Modifier.align(Alignment.BottomStart).padding(bottom = if (isMainMode) 48.dp else 16.dp), - ) - - // 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()) - } - if (updatedWp.icon == 0) { - finalWp = finalWp.copy(icon = 0x1F4CD) - } - mapViewModel.sendWaypoint(finalWp) - editingWaypoint = null - }, - onDeleteClicked = { wpToDelete -> - if (wpToDelete.locked_to == 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), - 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 }, - ) - }, - isLocationTrackingEnabled = isLocationTrackingEnabled, - onToggleLocationTracking = { - if (locationPermissionsState.allPermissionsGranted) { - isLocationTrackingEnabled = !isLocationTrackingEnabled - if (!isLocationTrackingEnabled) { - followPhoneBearing = false - } - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() - } - }, - bearing = cameraPositionState.position.bearing, - onCompassClick = { - if (isLocationTrackingEnabled) { - followPhoneBearing = !followPhoneBearing - } else { - coroutineScope.launch { - try { - val currentPosition = cameraPositionState.position - val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build() - cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition)) - Logger.d { "Oriented map to north" } - } catch (e: IllegalStateException) { - Logger.d { "Error orienting map to north: ${e.message}" } - } - } - } - }, - followPhoneBearing = followPhoneBearing, - showRefresh = showRefresh, - isRefreshing = isRefreshingLayers, - onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() }, - ) - } - - // --- Bottom sheets & dialogs --- - if (showLayersBottomSheet) { - ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) { - CustomMapLayersSheet( - mapLayers = mapLayers, - onToggleVisibility = onToggleVisibility, - onRemoveLayer = onRemoveLayer, - onAddLayerClicked = onAddLayerClicked, - onRefreshLayer = { mapViewModel.refreshMapLayer(it) }, - onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) }, - ) - } - } - showClusterItemsDialog?.let { - ClusterItemsListDialog( - items = it, - onDismiss = { showClusterItemsDialog = null }, - onItemClick = { item -> - navigateToNodeDetails(item.node.num) - showClusterItemsDialog = null - }, - ) - } - if (showCustomTileManagerSheet) { - ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) { - CustomTileProviderManagerSheet(mapViewModel = mapViewModel) - } - } -} - -// region --- Main Map Content --- - -@Suppress("LongParameterList") -@OptIn(MapsComposeExperimentalApi::class) -@Composable -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(), - ), - ) - } - Logger.d { "Cluster clicked! $cluster" } - } - true - }, - ) - - WaypointMarkers( - displayableWaypoints = displayableWaypoints, - mapFilterState = mapFilterState, - myNodeNum = myNodeNum ?: 0, - isConnected = isConnected, - onEditWaypointRequest = onEditWaypointRequest, - selectedWaypointId = selectedWaypointId, - ) - - mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } } -} - -// endregion - -// region --- Node Track Overlay --- - -/** - * Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from - * transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a - * [TripOrigin] dot with an info-window on tap. - * - * When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and - * elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization. - */ -@OptIn(MapsComposeExperimentalApi::class) -@Composable -@Suppress("LongMethod") -private fun NodeTrackOverlay( - focusedNode: Node, - sortedPositions: List, - displayUnits: DisplayUnits, - myNodeNum: Int?, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { - val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite - val activeNodeZIndex = if (isHighPriority) 5f else 4f - val selectedColor = MaterialTheme.colorScheme.primary - - sortedPositions.forEachIndexed { index, position -> - key(position.time) { - val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) - val alpha = - if (sortedPositions.size > 1) { - index.toFloat() / (sortedPositions.size.toFloat() - 1) - } else { - 1f - } - val isSelected = position.time == selectedPositionTime - val color = - if (isSelected) { - selectedColor - } else { - Color(focusedNode.colors.second).copy(alpha = alpha) - } - - if (index == sortedPositions.lastIndex) { - val chipIcon = rememberNodeChipDescriptor(focusedNode) - Marker( - state = markerState, - icon = chipIcon, - zIndex = activeNodeZIndex, - alpha = if (isHighPriority) 1.0f else 0.9f, - onClick = { - onPositionSelected?.invoke(position.time) - false // Allow default info window behavior - }, - ) - } else { - MarkerInfoWindowComposable( - state = markerState, - title = stringResource(Res.string.position), - snippet = formatAgo(position.time), - zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha, - onClick = { - onPositionSelected?.invoke(position.time) - false // Allow default info window behavior - }, - infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) }, - ) { - Icon( - imageVector = MeshtasticIcons.TripOrigin, - contentDescription = stringResource(Res.string.track_point), - tint = color, - modifier = if (isSelected) Modifier.size(32.dp) else Modifier, - ) - } - } - } - } - - // 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 -@Suppress("LongMethod") -private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayUnits = DisplayUnits.METRIC) { - @Composable - fun PositionRow(label: String, value: String) { - Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { - Text(label, style = MaterialTheme.typography.labelMedium) - Spacer(modifier = Modifier.width(16.dp)) - Text(value, style = MaterialTheme.typography.labelMedium) - } - } - - Card { - Column(modifier = Modifier.padding(8.dp)) { - PositionRow( - 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.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()) - } - } -} - -@Composable -private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String { - val speedInMps = position.ground_speed ?: 0 - val mpsText = "%d m/s".format(speedInMps) - return if (speedInMps > 10) { - when (displayUnits) { - DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph()) - DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph()) - } - } else { - mpsText - } -} - -// endregion - -// region --- Traceroute Map Content --- - -@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()) - val chipIcon = rememberNodeChipDescriptor(node) - Marker(state = markerState, icon = chipIcon, zIndex = 4f) - } -} - -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 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1]) - - headingPoints.lastIndex -> - SphericalUtil.computeHeading( - headingPoints[headingPoints.lastIndex - 1], - headingPoints[headingPoints.lastIndex], - ) - - else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1]) - } - } - - return points.mapIndexed { index, point -> - val heading = headings[index.coerceIn(0, headings.lastIndex)] - val perpendicularHeading = heading + (90.0 * sideMultiplier) - 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" -} - -@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/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt deleted file mode 100644 index 8a4a798a81..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ /dev/null @@ -1,690 +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 - -import android.app.Application -import android.net.Uri -import androidx.core.net.toFile -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger -import com.google.android.gms.maps.model.CameraPosition -import com.google.android.gms.maps.model.LatLng -import com.google.android.gms.maps.model.TileProvider -import com.google.android.gms.maps.model.UrlTileProvider -import com.google.maps.android.compose.CameraPositionState -import com.google.maps.android.compose.MapType -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsChannel -import io.ktor.http.isSuccess -import io.ktor.utils.io.jvm.javaio.toInputStream -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.app.map.model.CustomTileProviderConfig -import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs -import org.meshtastic.app.map.repository.CustomTileProviderRepository -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed -import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.Config -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.InputStream -import java.net.MalformedURLException -import java.net.URL -import kotlin.uuid.Uuid - -private const val TILE_SIZE = 256 - -@Serializable -data class MapCameraPosition( - val targetLat: Double, - val targetLng: Double, - val zoom: Float, - val tilt: Float, - val bearing: Float, -) - -@Suppress("TooManyFunctions", "LongParameterList") -@KoinViewModel -class MapViewModel( - private val application: Application, - private val dispatchers: CoroutineDispatchers, - private val httpClient: HttpClient, - mapPrefs: MapPrefs, - private val googleMapsPrefs: GoogleMapsPrefs, - nodeRepository: NodeRepository, - packetRepository: PacketRepository, - radioConfigRepository: RadioConfigRepository, - radioController: RadioController, - private val customTileProviderRepository: CustomTileProviderRepository, - uiPrefs: UiPrefs, - savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { - - private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) - val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() - - fun setWaypointId(id: Int?) { - if (_selectedWaypointId.value != id) { - _selectedWaypointId.value = id - if (id != null) { - viewModelScope.launch { - val wpMap = waypoints.first { it.containsKey(id) } - wpMap[id]?.let { packet -> - val waypoint = packet.waypoint!! - val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7) - cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f) - } - } - } - } - } - - private val targetLatLng = - googleMapsPrefs.cameraTargetLat.value - .takeIf { it != 0.0 } - ?.let { lat -> googleMapsPrefs.cameraTargetLng.value.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } } - ?: ourNodeInfo.value?.position?.toLatLng() - ?: LatLng(0.0, 0.0) - - val cameraPositionState = - CameraPositionState( - position = - CameraPosition( - targetLatLng, - googleMapsPrefs.cameraZoom.value, - googleMapsPrefs.cameraTilt.value, - googleMapsPrefs.cameraBearing.value, - ), - ) - - val theme: StateFlow = uiPrefs.theme - - private val _errorFlow = MutableSharedFlow() - val errorFlow: Flow = _errorFlow.asFlow() - - val customTileProviderConfigs: StateFlow> = - customTileProviderRepository.getCustomTileProviders().stateInWhileSubscribed(initialValue = emptyList()) - - private val _selectedCustomTileProviderUrl = MutableStateFlow(null) - val selectedCustomTileProviderUrl: StateFlow = _selectedCustomTileProviderUrl.asStateFlow() - - private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL) - val selectedGoogleMapType: StateFlow = _selectedGoogleMapType.asStateFlow() - - val displayUnits = - radioConfigRepository.deviceProfileFlow - .mapNotNull { it.config?.display?.units } - .stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC) - - fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) { - viewModelScope.launch { - if ( - name.isBlank() || - (urlTemplate.isBlank() && localUri == null) || - (localUri == null && !isValidTileUrlTemplate(urlTemplate)) - ) { - _errorFlow.emit("Invalid name, URL template, or local URI for custom tile provider.") - return@launch - } - if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) { - _errorFlow.emit("Custom tile provider with name '$name' already exists.") - return@launch - } - - var finalLocalUri = localUri - if (localUri != null) { - try { - val uri = Uri.parse(localUri) - val extension = "mbtiles" - val finalFileName = "mbtiles_${Uuid.random()}.$extension" - val copiedUri = copyFileToInternalStorage(uri, finalFileName) - if (copiedUri != null) { - finalLocalUri = copiedUri.toString() - } else { - _errorFlow.emit("Failed to copy MBTiles file to internal storage.") - return@launch - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error processing local URI" } - _errorFlow.emit("Error processing local URI for MBTiles.") - return@launch - } - } - - val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate, localUri = finalLocalUri) - customTileProviderRepository.addCustomTileProvider(newConfig) - } - } - - fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) { - viewModelScope.launch { - if ( - configToUpdate.name.isBlank() || - (configToUpdate.urlTemplate.isBlank() && configToUpdate.localUri == null) || - (configToUpdate.localUri == null && !isValidTileUrlTemplate(configToUpdate.urlTemplate)) - ) { - _errorFlow.emit("Invalid name, URL template, or local URI for updating custom tile provider.") - return@launch - } - val existingConfigs = customTileProviderConfigs.value - if ( - existingConfigs.any { - it.id != configToUpdate.id && it.name.equals(configToUpdate.name, ignoreCase = true) - } - ) { - _errorFlow.emit("Another custom tile provider with name '${configToUpdate.name}' already exists.") - return@launch - } - - customTileProviderRepository.updateCustomTileProvider(configToUpdate) - - val originalConfig = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id) - if ( - _selectedCustomTileProviderUrl.value != null && - originalConfig?.urlTemplate == _selectedCustomTileProviderUrl.value - ) { - // No change needed if URL didn't change, or handle if it did - } else if (originalConfig != null && _selectedCustomTileProviderUrl.value != originalConfig.urlTemplate) { - val currentlySelectedConfig = - customTileProviderConfigs.value.find { it.urlTemplate == _selectedCustomTileProviderUrl.value } - if (currentlySelectedConfig?.id == configToUpdate.id) { - _selectedCustomTileProviderUrl.value = configToUpdate.urlTemplate - } - } - } - } - - fun removeCustomTileProvider(configId: String) { - viewModelScope.launch { - val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId) - customTileProviderRepository.deleteCustomTileProvider(configId) - - if (configToRemove != null) { - if ( - _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate || - _selectedCustomTileProviderUrl.value == configToRemove.localUri - ) { - _selectedCustomTileProviderUrl.value = null - // Also clear from prefs - googleMapsPrefs.setSelectedCustomTileUrl(null) - } - - if (configToRemove.localUri != null) { - val uri = Uri.parse(configToRemove.localUri) - deleteFileToInternalStorage(uri) - } - } - } - } - - fun selectCustomTileProvider(config: CustomTileProviderConfig?) { - if (config != null) { - if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) { - Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}") - _selectedCustomTileProviderUrl.value = null - googleMapsPrefs.setSelectedCustomTileUrl(null) - return - } - // Use localUri if present, otherwise urlTemplate - val selectedUrl = config.localUri ?: config.urlTemplate - _selectedCustomTileProviderUrl.value = selectedUrl - _selectedGoogleMapType.value = MapType.NONE - googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl) - googleMapsPrefs.setSelectedGoogleMapType(null) - } else { - _selectedCustomTileProviderUrl.value = null - _selectedGoogleMapType.value = MapType.NORMAL - googleMapsPrefs.setSelectedCustomTileUrl(null) - googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name) - } - } - - fun setSelectedGoogleMapType(mapType: MapType) { - _selectedGoogleMapType.value = mapType - _selectedCustomTileProviderUrl.value = null // Clear custom selection - googleMapsPrefs.setSelectedGoogleMapType(mapType.name) - googleMapsPrefs.setSelectedCustomTileUrl(null) - } - - private var currentTileProvider: TileProvider? = null - - fun getTileProvider(config: CustomTileProviderConfig?): TileProvider? { - if (config == null) { - (currentTileProvider as? MBTilesProvider)?.close() - currentTileProvider = null - return null - } - - val selectedUrl = config.localUri ?: config.urlTemplate - if (currentTileProvider != null && _selectedCustomTileProviderUrl.value == selectedUrl) { - return currentTileProvider - } - - // Close previous if it was a local provider - (currentTileProvider as? MBTilesProvider)?.close() - - val newProvider = - if (config.isLocal) { - val uri = Uri.parse(config.localUri) - val file = - try { - uri.toFile() - } catch (e: Exception) { - File(uri.path ?: "") - } - if (file.exists()) { - MBTilesProvider(file) - } else { - Logger.withTag("MapViewModel").e("Local MBTiles file does not exist: ${config.localUri}") - null - } - } else { - val urlString = config.urlTemplate - if (!isValidTileUrlTemplate(urlString)) { - Logger.withTag("MapViewModel") - .e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString") - null - } else { - object : UrlTileProvider(TILE_SIZE, TILE_SIZE) { - override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? { - val subdomains = listOf("a", "b", "c") - val subdomain = subdomains[(x + y) % subdomains.size] - val formattedUrl = - urlString - .replace("{s}", subdomain, ignoreCase = true) - .replace("{z}", zoom.toString(), ignoreCase = true) - .replace("{x}", x.toString(), ignoreCase = true) - .replace("{y}", y.toString(), ignoreCase = true) - return try { - URL(formattedUrl) - } catch (e: MalformedURLException) { - Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" } - null - } - } - } - } - } - - currentTileProvider = newProvider - return newProvider - } - - private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) && - urlTemplate.contains("{x}", ignoreCase = true) && - urlTemplate.contains("{y}", ignoreCase = true) - - private val _mapLayers = MutableStateFlow>(emptyList()) - val mapLayers: StateFlow> = _mapLayers.asStateFlow() - - init { - viewModelScope.launch { - customTileProviderRepository.getCustomTileProviders().first() - loadPersistedMapType() - } - loadPersistedLayers() - - selectedWaypointId.value?.let { wpId -> - viewModelScope.launch { - val wpMap = waypoints.first { it.containsKey(wpId) } - wpMap[wpId]?.let { packet -> - val waypoint = packet.waypoint!! - val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7) - cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f) - } - } - } - } - - fun saveCameraPosition(cameraPosition: CameraPosition) { - viewModelScope.launch { - googleMapsPrefs.setCameraTargetLat(cameraPosition.target.latitude) - googleMapsPrefs.setCameraTargetLng(cameraPosition.target.longitude) - googleMapsPrefs.setCameraZoom(cameraPosition.zoom) - googleMapsPrefs.setCameraTilt(cameraPosition.tilt) - googleMapsPrefs.setCameraBearing(cameraPosition.bearing) - } - } - - private fun loadPersistedMapType() { - val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value - if (savedCustomUrl != null) { - // Check if this custom provider still exists - if ( - customTileProviderConfigs.value.any { it.urlTemplate == savedCustomUrl } && - isValidTileUrlTemplate(savedCustomUrl) - ) { - _selectedCustomTileProviderUrl.value = savedCustomUrl - _selectedGoogleMapType.value = - MapType.NONE // MapType.NONE to hide google basemap when using custom provider - } else { - // The saved custom URL is no longer valid or doesn't exist, remove preference - googleMapsPrefs.setSelectedCustomTileUrl(null) - // Fallback to default Google Map type - _selectedGoogleMapType.value = MapType.NORMAL - } - } else { - val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value - try { - _selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name) - } catch (e: IllegalArgumentException) { - Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" } - _selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name - googleMapsPrefs.setSelectedGoogleMapType(null) - } - } - } - - private fun loadPersistedLayers() { - viewModelScope.launch(dispatchers.io) { - try { - val layersDir = File(application.filesDir, "map_layers") - if (layersDir.exists() && layersDir.isDirectory) { - val persistedLayerFiles = layersDir.listFiles() - - if (persistedLayerFiles != null) { - val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value - val loadedItems = - persistedLayerFiles.mapNotNull { file -> - if (file.isFile) { - val layerType = - when (file.extension.lowercase()) { - "kml", - "kmz", - -> LayerType.KML - - "geojson", - "json", - -> LayerType.GEOJSON - - else -> null - } - - layerType?.let { - val uri = Uri.fromFile(file) - MapLayerItem( - name = file.nameWithoutExtension, - uri = uri, - isVisible = !hiddenLayerUrls.contains(uri.toString()), - layerType = it, - ) - } - } else { - null - } - } - - val networkItems = - googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString -> - try { - val parts = networkString.split("|:|") - if (parts.size == 3) { - val id = parts[0] - val name = parts[1] - val uri = Uri.parse(parts[2]) - MapLayerItem( - id = id, - name = name, - uri = uri, - isVisible = !hiddenLayerUrls.contains(uri.toString()), - layerType = LayerType.KML, - isNetwork = true, - ) - } else { - null - } - } catch (e: Exception) { - null - } - } - - _mapLayers.value = loadedItems + networkItems - if (_mapLayers.value.isNotEmpty()) { - Logger.withTag("MapViewModel").i("Loaded ${_mapLayers.value.size} persisted map layers.") - } - } - } else { - Logger.withTag("MapViewModel").i("Map layers directory does not exist. No layers loaded.") - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error loading persisted map layers" } - _mapLayers.value = emptyList() - } - } - } - - fun addMapLayer(uri: Uri, fileName: String?) { - viewModelScope.launch { - val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.value.size + 1}" - - val extension = - fileName?.substringAfterLast('.', "")?.lowercase() - ?: application.contentResolver.getType(uri)?.split('/')?.last() - - val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz") - val geoJsonExtensions = listOf("geojson", "json") - - val layerType = - when (extension) { - in kmlExtensions -> LayerType.KML - in geoJsonExtensions -> LayerType.GEOJSON - else -> null - } - - if (layerType == null) { - Logger.withTag("MapViewModel").e("Unsupported map layer file type: $extension") - return@launch - } - - val finalFileName = - if (fileName != null) { - "$layerName.$extension" - } else { - "layer_${Uuid.random()}.$extension" - } - - val localFileUri = copyFileToInternalStorage(uri, finalFileName) - - if (localFileUri != null) { - val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType) - _mapLayers.value = _mapLayers.value + newItem - } else { - Logger.withTag("MapViewModel").e("Failed to copy file to internal storage.") - } - } - } - - fun addNetworkMapLayer(name: String, url: String) { - viewModelScope.launch { - if (name.isBlank() || url.isBlank()) { - _errorFlow.emit("Invalid name or URL for network layer.") - return@launch - } - try { - val uri = Uri.parse(url) - if (uri.scheme != "http" && uri.scheme != "https") { - _errorFlow.emit("URL must be http or https.") - return@launch - } - - val path = uri.path?.lowercase() ?: "" - val layerType = - when { - path.endsWith(".geojson") || path.endsWith(".json") -> LayerType.GEOJSON - else -> LayerType.KML // Default to KML - } - - val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true) - _mapLayers.value = _mapLayers.value + newItem - - val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}" - googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString) - } catch (e: Exception) { - _errorFlow.emit("Invalid URL.") - } - } - } - - private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) { - try { - val inputStream = application.contentResolver.openInputStream(uri) - val directory = File(application.filesDir, "map_layers") - if (!directory.exists()) { - directory.mkdirs() - } - val outputFile = File(directory, fileName) - val outputStream = FileOutputStream(outputFile) - - inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } } - Uri.fromFile(outputFile) - } catch (e: IOException) { - Logger.withTag("MapViewModel").e(e) { "Error copying file to internal storage" } - null - } - } - - fun toggleLayerVisibility(layerId: String) { - var toggledLayer: MapLayerItem? = null - val updatedLayers = - _mapLayers.value.map { - if (it.id == layerId) { - toggledLayer = it.copy(isVisible = !it.isVisible) - toggledLayer - } else { - it - } - } - _mapLayers.value = updatedLayers - - toggledLayer?.let { - if (it.isVisible) { - googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString()) - } else { - googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString()) - } - } - } - - fun removeMapLayer(layerId: String) { - viewModelScope.launch { - val layerToRemove = _mapLayers.value.find { it.id == layerId } - layerToRemove?.uri?.let { uri -> - if (layerToRemove.isNetwork) { - googleMapsPrefs.setNetworkMapLayers( - googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(), - ) - } else { - deleteFileToInternalStorage(uri) - } - googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString()) - } - _mapLayers.value = _mapLayers.value.filterNot { it.id == layerId } - } - } - - fun refreshMapLayer(layerId: String) { - viewModelScope.launch { - _mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = true) else it } } - // By resetting the layer data in the UI (implied by just refreshing), - // we trigger a reload in the Composable. - _mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = false) else it } } - } - } - - fun refreshAllVisibleNetworkLayers() { - _mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) } - } - - private suspend fun deleteFileToInternalStorage(uri: Uri) { - withContext(dispatchers.io) { - try { - val file = uri.toFile() - if (file.exists()) { - file.delete() - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error deleting file from internal storage" } - } - } - } - - @Suppress("Recycle") - suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? { - val uriToLoad = layerItem.uri ?: return null - return withContext(dispatchers.io) { - try { - if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) { - val response = httpClient.get(uriToLoad.toString()) - if (!response.status.isSuccess()) { - Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" } - return@withContext null - } - response.bodyAsChannel().toInputStream() - } else { - application.contentResolver.openInputStream(uriToLoad) - } - } catch (e: Exception) { - Logger.withTag("MapViewModel").e(e) { "Error opening InputStream from URI: $uriToLoad" } - null - } - } - } - - override fun onCleared() { - super.onCleared() - (currentTileProvider as? MBTilesProvider)?.close() - } - - override fun getUser(userId: String?) = - nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST) -} - -enum class LayerType { - KML, - GEOJSON, -} - -data class MapLayerItem( - val id: String = Uuid.random().toString(), - val name: String, - val uri: Uri? = null, - val isVisible: Boolean = true, - val layerType: LayerType, - val isNetwork: Boolean = false, - val isRefreshing: Boolean = false, -) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt deleted file mode 100644 index d69e3f316b..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt +++ /dev/null @@ -1,76 +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.component - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ListItem -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.model.NodeClusterItem -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.nodes_at_this_location -import org.meshtastic.core.resources.okay -import org.meshtastic.core.ui.component.NodeChip - -@Composable -fun ClusterItemsListDialog( - items: List, - onDismiss: () -> Unit, - onItemClick: (NodeClusterItem) -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(Res.string.nodes_at_this_location)) }, - text = { - // Use a LazyColumn for potentially long lists of items - LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) { - items(items, key = { it.node.num }) { item -> - ClusterDialogListItem(item = item, onClick = { onItemClick(item) }) - } - } - }, - confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.okay)) } }, - ) -} - -@Composable -private fun ClusterDialogListItem(item: NodeClusterItem, onClick: () -> Unit, modifier: Modifier = Modifier) { - ListItem( - leadingContent = { NodeChip(node = item.node) }, - headlineContent = { Text(item.nodeTitle) }, - supportingContent = { - if (item.nodeSnippet.isNotBlank()) { - Text(item.nodeSnippet) - } - }, - modifier = - modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 8.dp, vertical = 4.dp), // Add some padding around list items - ) -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt deleted file mode 100644 index 91c5da2d8c..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt +++ /dev/null @@ -1,216 +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.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconToggleButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -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.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapLayerItem -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.add_layer -import org.meshtastic.core.resources.add_network_layer -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.hide_layer -import org.meshtastic.core.resources.manage_map_layers -import org.meshtastic.core.resources.map_layer_formats -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.network_layer_url_hint -import org.meshtastic.core.resources.no_map_layers_loaded -import org.meshtastic.core.resources.refresh -import org.meshtastic.core.resources.remove_layer -import org.meshtastic.core.resources.save -import org.meshtastic.core.resources.show_layer -import org.meshtastic.core.resources.url -import org.meshtastic.core.ui.component.MeshtasticDialog -import org.meshtastic.core.ui.icon.Delete -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Refresh -import org.meshtastic.core.ui.icon.Visibility -import org.meshtastic.core.ui.icon.VisibilityOff - -@Suppress("LongMethod") -@Composable -@OptIn(ExperimentalMaterial3Api::class) -fun CustomMapLayersSheet( - mapLayers: List, - onToggleVisibility: (String) -> Unit, - onRemoveLayer: (String) -> Unit, - onAddLayerClicked: () -> Unit, - onRefreshLayer: (String) -> Unit, - onAddNetworkLayer: (String, String) -> Unit, -) { - var showAddNetworkLayerDialog by remember { mutableStateOf(false) } - LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { - item { - Text( - modifier = Modifier.padding(16.dp), - text = stringResource(Res.string.manage_map_layers), - style = MaterialTheme.typography.headlineSmall, - ) - HorizontalDivider() - } - item { - Text( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp), - text = stringResource(Res.string.map_layer_formats), - style = MaterialTheme.typography.bodySmall, - ) - } - - if (mapLayers.isEmpty()) { - item { - Text( - modifier = Modifier.padding(16.dp), - text = stringResource(Res.string.no_map_layers_loaded), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - items(mapLayers, key = { it.id }) { layer -> - ListItem( - headlineContent = { Text(layer.name) }, - trailingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (layer.isNetwork) { - if (layer.isRefreshing) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp).padding(4.dp), - strokeWidth = 2.dp, - ) - } else { - IconButton(onClick = { onRefreshLayer(layer.id) }) { - Icon( - imageVector = MeshtasticIcons.Refresh, - contentDescription = stringResource(Res.string.refresh), - ) - } - } - } - IconToggleButton( - checked = layer.isVisible, - onCheckedChange = { onToggleVisibility(layer.id) }, - ) { - Icon( - imageVector = - if (layer.isVisible) { - MeshtasticIcons.Visibility - } else { - MeshtasticIcons.VisibilityOff - }, - contentDescription = - stringResource( - if (layer.isVisible) { - Res.string.hide_layer - } else { - Res.string.show_layer - }, - ), - ) - } - IconButton(onClick = { onRemoveLayer(layer.id) }) { - Icon( - imageVector = MeshtasticIcons.Delete, - contentDescription = stringResource(Res.string.remove_layer), - ) - } - } - }, - ) - HorizontalDivider() - } - } - item { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button(modifier = Modifier.fillMaxWidth(), onClick = onAddLayerClicked) { - Text(stringResource(Res.string.add_layer)) - } - Button(modifier = Modifier.fillMaxWidth(), onClick = { showAddNetworkLayerDialog = true }) { - Text(stringResource(Res.string.add_network_layer)) - } - } - } - } - - if (showAddNetworkLayerDialog) { - AddNetworkLayerDialog( - onDismiss = { showAddNetworkLayerDialog = false }, - onConfirm = { name, url -> - onAddNetworkLayer(name, url) - showAddNetworkLayerDialog = false - }, - ) - } -} - -@Composable -fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) { - var name by remember { mutableStateOf("") } - var url by remember { mutableStateOf("") } - - MeshtasticDialog( - onDismiss = onDismiss, - title = stringResource(Res.string.add_network_layer), - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text(stringResource(Res.string.name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = url, - onValueChange = { url = it }, - label = { Text(stringResource(Res.string.url)) }, - placeholder = { Text(stringResource(Res.string.network_layer_url_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - onConfirm = { onConfirm(name, url) }, - confirmTextRes = Res.string.save, - dismissTextRes = Res.string.cancel, - ) -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt deleted file mode 100644 index 198a857a92..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt +++ /dev/null @@ -1,324 +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.component - -import android.content.Intent -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -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.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.collectLatest -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.app.map.model.CustomTileProviderConfig -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.add_custom_tile_source -import org.meshtastic.core.resources.add_local_mbtiles_file -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.delete_custom_tile_source -import org.meshtastic.core.resources.edit_custom_tile_source -import org.meshtastic.core.resources.local_mbtiles_file -import org.meshtastic.core.resources.manage_custom_tile_sources -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.name_cannot_be_empty -import org.meshtastic.core.resources.no_custom_tile_sources_found -import org.meshtastic.core.resources.provider_name_exists -import org.meshtastic.core.resources.save -import org.meshtastic.core.resources.url_cannot_be_empty -import org.meshtastic.core.resources.url_must_contain_placeholders -import org.meshtastic.core.resources.url_template -import org.meshtastic.core.resources.url_template_hint -import org.meshtastic.core.ui.component.MeshtasticDialog -import org.meshtastic.core.ui.icon.Delete -import org.meshtastic.core.ui.icon.Edit -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.util.showToast - -@Suppress("LongMethod") -@Composable -fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) { - val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle() - var editingConfig by remember { mutableStateOf(null) } - var showEditDialog by remember { mutableStateOf(false) } - val context = LocalContext.current - - val mbtilesPickerLauncher = - rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { - result.data?.data?.let { uri -> - val fileName = uri.getFileName(context) - val baseName = fileName.substringBeforeLast('.') - mapViewModel.addCustomTileProvider( - name = baseName, - urlTemplate = "", // Empty for local - localUri = uri.toString(), - ) - } - } - } - - LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { errorMessage -> context.showToast(errorMessage) } } - - if (showEditDialog) { - AddEditCustomTileProviderDialog( - config = editingConfig, - onDismiss = { showEditDialog = false }, - onSave = { name, url -> - if (editingConfig == null) { // Adding new - mapViewModel.addCustomTileProvider(name, url) - } else { // Editing existing - mapViewModel.updateCustomTileProvider(editingConfig!!.copy(name = name, urlTemplate = url)) - } - showEditDialog = false - }, - mapViewModel = mapViewModel, - ) - } - - LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { - item { - Text( - text = stringResource(Res.string.manage_custom_tile_sources), - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(16.dp), - ) - HorizontalDivider() - } - - if (customTileProviders.isEmpty()) { - item { - Text( - text = stringResource(Res.string.no_custom_tile_sources_found), - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - items(customTileProviders, key = { it.id }) { config -> - ListItem( - headlineContent = { Text(config.name) }, - supportingContent = { - if (config.isLocal) { - Text( - stringResource(Res.string.local_mbtiles_file), - style = MaterialTheme.typography.bodySmall, - ) - } else { - Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall) - } - }, - trailingContent = { - Row { - IconButton( - onClick = { - editingConfig = config - showEditDialog = true - }, - ) { - Icon( - MeshtasticIcons.Edit, - contentDescription = stringResource(Res.string.edit_custom_tile_source), - ) - } - IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) { - Icon( - MeshtasticIcons.Delete, - contentDescription = stringResource(Res.string.delete_custom_tile_source), - ) - } - } - }, - ) - HorizontalDivider() - } - } - - item { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - editingConfig = null - showEditDialog = true - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(Res.string.add_custom_tile_source)) - } - - Button( - onClick = { - val intent = - Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - } - mbtilesPickerLauncher.launch(intent) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(Res.string.add_local_mbtiles_file)) - } - } - } - } -} - -@Suppress("LongMethod") -@Composable -private fun AddEditCustomTileProviderDialog( - config: CustomTileProviderConfig?, - onDismiss: () -> Unit, - onSave: (String, String) -> Unit, - mapViewModel: MapViewModel, -) { - var name by rememberSaveable { mutableStateOf(config?.name ?: "") } - var url by rememberSaveable { mutableStateOf(config?.urlTemplate ?: "") } - var nameError by remember { mutableStateOf(null) } - var urlError by remember { mutableStateOf(null) } - val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle() - - val emptyNameError = stringResource(Res.string.name_cannot_be_empty) - val providerNameExistsError = stringResource(Res.string.provider_name_exists) - val urlCannotBeEmptyError = stringResource(Res.string.url_cannot_be_empty) - val urlMustContainPlaceholdersError = stringResource(Res.string.url_must_contain_placeholders) - - fun validateAndSave() { - val currentNameError = - validateName(name, customTileProviders, config?.id, emptyNameError, providerNameExistsError) - val currentUrlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError) - - nameError = currentNameError - urlError = currentUrlError - - if (currentNameError == null && currentUrlError == null) { - onSave(name, url) - } - } - - MeshtasticDialog( - onDismiss = onDismiss, - title = - if (config == null) { - stringResource(Res.string.add_custom_tile_source) - } else { - stringResource(Res.string.edit_custom_tile_source) - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = name, - onValueChange = { - name = it - nameError = null - }, - label = { Text(stringResource(Res.string.name)) }, - isError = nameError != null, - supportingText = { nameError?.let { Text(it) } }, - singleLine = true, - ) - OutlinedTextField( - value = url, - onValueChange = { - url = it - urlError = null - }, - label = { Text(stringResource(Res.string.url_template)) }, - isError = urlError != null, - supportingText = { - if (urlError != null) { - Text(urlError!!) - } else { - Text(stringResource(Res.string.url_template_hint)) - } - }, - singleLine = false, - maxLines = 2, - ) - } - }, - onConfirm = { validateAndSave() }, - confirmTextRes = Res.string.save, - dismissTextRes = Res.string.cancel, - ) -} - -private fun validateName( - name: String, - providers: List, - currentId: String?, - emptyNameError: String, - nameExistsError: String, -): String? = if (name.isBlank()) { - emptyNameError -} else if (providers.any { it.name.equals(name, ignoreCase = true) && it.id != currentId }) { - nameExistsError -} else { - null -} - -private fun validateUrl(url: String, emptyUrlError: String, mustContainPlaceholdersError: String): String? = - if (url.isBlank()) { - emptyUrlError - } else if ( - !url.contains("{z}", ignoreCase = true) || - !url.contains("{x}", ignoreCase = true) || - !url.contains("{y}", ignoreCase = true) - ) { - mustContainPlaceholdersError - } else { - null - } - -private fun android.net.Uri.getFileName(context: android.content.Context): String { - var name = this.lastPathSegment ?: "mbtiles_file" - 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 -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt deleted file mode 100644 index 06a063ddf0..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt +++ /dev/null @@ -1,372 +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.component - -import android.app.DatePickerDialog -import android.app.TimePickerDialog -import android.widget.DatePicker -import android.widget.TimePicker -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -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.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.datetime.LocalDate -import kotlinx.datetime.Month -import kotlinx.datetime.atTime -import kotlinx.datetime.number -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.systemTimeZone -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.date -import org.meshtastic.core.resources.delete -import org.meshtastic.core.resources.description -import org.meshtastic.core.resources.expires -import org.meshtastic.core.resources.locked -import org.meshtastic.core.resources.name -import org.meshtastic.core.resources.send -import org.meshtastic.core.resources.time -import org.meshtastic.core.resources.waypoint_edit -import org.meshtastic.core.resources.waypoint_new -import org.meshtastic.core.ui.emoji.EmojiPickerDialog -import org.meshtastic.core.ui.icon.CalendarMonth -import org.meshtastic.core.ui.icon.Lock -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.proto.Waypoint -import kotlin.time.Duration.Companion.hours - -@OptIn(ExperimentalMaterial3Api::class) -@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber") -@Composable -fun EditWaypointDialog( - waypoint: Waypoint, - onSendClicked: (Waypoint) -> Unit, - onDeleteClicked: (Waypoint) -> Unit, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, -) { - var waypointInput by remember { mutableStateOf(waypoint) } - val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit - val defaultEmoji = 0x1F4CD // 📍 Round Pushpin - val currentEmojiCodepoint = if (waypointInput.icon == 0) defaultEmoji else waypointInput.icon - var showEmojiPickerView by remember { mutableStateOf(false) } - - val context = LocalContext.current - val tz = systemTimeZone - - // Initialize date and time states from waypointInput.expire - var selectedDateString by remember { mutableStateOf("") } - var selectedTimeString by remember { mutableStateOf("") } - var isExpiryEnabled by remember { - mutableStateOf(waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) - } - - val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } - val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) } - dateFormat.timeZone = java.util.TimeZone.getDefault() - timeFormat.timeZone = java.util.TimeZone.getDefault() - - LaunchedEffect(waypointInput.expire, isExpiryEnabled) { - val expireValue = waypointInput.expire - if (isExpiryEnabled) { - if (expireValue != 0 && expireValue != Int.MAX_VALUE) { - val instant = kotlin.time.Instant.fromEpochSeconds(expireValue.toLong()) - val date = java.util.Date(instant.toEpochMilliseconds()) - selectedDateString = dateFormat.format(date) - selectedTimeString = timeFormat.format(date) - } else { // If enabled but not set, default to 8 hours from now - val futureInstant = kotlin.time.Clock.System.now() + 8.hours - val date = java.util.Date(futureInstant.toEpochMilliseconds()) - selectedDateString = dateFormat.format(date) - selectedTimeString = timeFormat.format(date) - waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt()) - } - } else { - selectedDateString = "" - selectedTimeString = "" - } - } - - if (!showEmojiPickerView) { - AlertDialog( - onDismissRequest = onDismissRequest, - title = { - Text( - text = stringResource(title), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - }, - text = { - Column(modifier = modifier.fillMaxWidth()) { - OutlinedTextField( - value = waypointInput.name, - onValueChange = { waypointInput = waypointInput.copy(name = it.take(29)) }, - label = { Text(stringResource(Res.string.name)) }, - singleLine = true, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), - modifier = Modifier.fillMaxWidth(), - trailingIcon = { - IconButton(onClick = { showEmojiPickerView = true }) { - Text( - text = String(Character.toChars(currentEmojiCodepoint)), - modifier = - Modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape) - .padding(6.dp), - fontSize = 20.sp, - ) - } - }, - ) - Spacer(modifier = Modifier.size(8.dp)) - OutlinedTextField( - value = waypointInput.description, - onValueChange = { waypointInput = waypointInput.copy(description = it.take(99)) }, - label = { Text(stringResource(Res.string.description)) }, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = {}), - modifier = Modifier.fillMaxWidth(), - minLines = 2, - maxLines = 3, - ) - Spacer(modifier = Modifier.size(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Image( - imageVector = MeshtasticIcons.Lock, - contentDescription = stringResource(Res.string.locked), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(Res.string.locked)) - } - Switch( - checked = waypointInput.locked_to != 0, - onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) }, - ) - } - Spacer(modifier = Modifier.size(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Image( - imageVector = MeshtasticIcons.CalendarMonth, - contentDescription = stringResource(Res.string.expires), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(Res.string.expires)) - } - Switch( - checked = isExpiryEnabled, - onCheckedChange = { checked -> - isExpiryEnabled = checked - if (checked) { - val expireValue = waypointInput.expire - // Default to 8 hours from now if not already set - if (expireValue == 0 || expireValue == Int.MAX_VALUE) { - val futureInstant = kotlin.time.Clock.System.now() + 8.hours - waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt()) - } - } else { - waypointInput = waypointInput.copy(expire = Int.MAX_VALUE) - } - }, - ) - } - - if (isExpiryEnabled) { - val currentInstant = - (waypointInput.expire).let { - if (it != 0 && it != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(it.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - val ldt = currentInstant.toLocalDateTime(tz) - - val datePickerDialog = - DatePickerDialog( - context, - { _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int -> - val currentLdt = - (waypointInput.expire) - .let { - if (it != 0 && it != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(it.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - .toLocalDateTime(tz) - - val newLdt = - LocalDate( - year = selectedYear, - month = Month(selectedMonth + 1), - day = selectedDay, - ) - .atTime( - hour = currentLdt.hour, - minute = currentLdt.minute, - second = currentLdt.second, - nanosecond = currentLdt.nanosecond, - ) - waypointInput = - waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.year, - ldt.month.number - 1, - ldt.day, - ) - - val timePickerDialog = - TimePickerDialog( - context, - { _: TimePicker, selectedHour: Int, selectedMinute: Int -> - val currentLdt = - (waypointInput.expire) - .let { - if (it != 0 && it != Int.MAX_VALUE) { - kotlin.time.Instant.fromEpochSeconds(it.toLong()) - } else { - kotlin.time.Clock.System.now() + 8.hours - } - } - .toLocalDateTime(tz) - - val newLdt = - LocalDate( - year = currentLdt.year, - month = currentLdt.month, - day = currentLdt.day, - ) - .atTime( - hour = selectedHour, - minute = selectedMinute, - second = currentLdt.second, - nanosecond = currentLdt.nanosecond, - ) - waypointInput = - waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt()) - }, - ldt.hour, - ldt.minute, - android.text.format.DateFormat.is24HourFormat(context), - ) - Spacer(modifier = Modifier.size(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedDateString, - style = MaterialTheme.typography.bodyMedium, - ) - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) } - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedTimeString, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - } - }, - confirmButton = { - Row( - modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp), - horizontalArrangement = Arrangement.End, - ) { - if (waypoint.id != 0) { - TextButton( - onClick = { onDeleteClicked(waypointInput) }, - modifier = Modifier.padding(end = 8.dp), - ) { - Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error) - } - } - Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right - TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) { - Text(stringResource(Res.string.cancel)) - } - Button(onClick = { onSendClicked(waypointInput) }, enabled = (waypointInput.name).isNotBlank()) { - Text(stringResource(Res.string.send)) - } - } - }, - dismissButton = null, // Using custom buttons in confirmButton Row - modifier = modifier, - ) - } else { - EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { selectedEmoji -> - showEmojiPickerView = false - waypointInput = waypointInput.copy(icon = selectedEmoji.codePointAt(0)) - } - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt deleted file mode 100644 index 8c8d63c2ac..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt +++ /dev/null @@ -1,165 +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 . - */ -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - -package org.meshtastic.app.map.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuGroup -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuDefaults -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.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.last_heard_filter_label -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.PinDrop -import org.meshtastic.feature.map.LastHeardFilter -import kotlin.math.roundToInt - -@Composable -internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) { - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.only_favorites)) }, - onClick = { mapViewModel.toggleOnlyFavorites() }, - leadingIcon = { - Icon( - imageVector = MeshtasticIcons.Favorite, - contentDescription = stringResource(Res.string.only_favorites), - ) - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.show_waypoints)) }, - onClick = { mapViewModel.toggleShowWaypointsOnMap() }, - leadingIcon = { - Icon( - imageVector = MeshtasticIcons.PinDrop, - contentDescription = stringResource(Res.string.show_waypoints), - ) - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.showWaypoints, - onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, - ) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.show_precision_circle)) }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - leadingIcon = { - Icon( - imageVector = MeshtasticIcons.Lens, - contentDescription = stringResource(Res.string.show_precision_circle), - ) - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - ) - }, - ) - } - 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 -internal fun NodeMapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) { - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - val filterOptions = LastHeardFilter.entries - val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardTrackFilter) - var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } - - Text( - text = - stringResource( - Res.string.last_heard_filter_label, - stringResource(mapFilterState.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/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt deleted file mode 100644 index a649e29624..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt +++ /dev/null @@ -1,120 +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 . - */ -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - -package org.meshtastic.app.map.component - -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuGroup -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.MenuDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.maps.android.compose.MapType -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.app.map.MapViewModel -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.manage_custom_tile_sources -import org.meshtastic.core.resources.map_type_hybrid -import org.meshtastic.core.resources.map_type_normal -import org.meshtastic.core.resources.map_type_satellite -import org.meshtastic.core.resources.map_type_terrain -import org.meshtastic.core.resources.selected_map_type -import org.meshtastic.core.ui.icon.Check -import org.meshtastic.core.ui.icon.MeshtasticIcons - -@Suppress("LongMethod") -@Composable -internal fun MapTypeDropdown( - expanded: Boolean, - onDismissRequest: () -> Unit, - mapViewModel: MapViewModel, - onManageCustomTileProvidersClicked: () -> Unit, -) { - val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle() - val selectedCustomUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle() - val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle() - - val googleMapTypes = - listOf( - stringResource(Res.string.map_type_normal) to MapType.NORMAL, - stringResource(Res.string.map_type_satellite) to MapType.SATELLITE, - stringResource(Res.string.map_type_terrain) to MapType.TERRAIN, - stringResource(Res.string.map_type_hybrid) to MapType.HYBRID, - ) - - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) { - googleMapTypes.forEach { (name, type) -> - DropdownMenuItem( - text = { Text(name) }, - onClick = { - mapViewModel.setSelectedGoogleMapType(type) - onDismissRequest() - }, - trailingIcon = - if (selectedCustomUrl == null && selectedGoogleMapType == type) { - { - Icon( - MeshtasticIcons.Check, - contentDescription = stringResource(Res.string.selected_map_type), - ) - } - } else { - null - }, - ) - } - } - - if (customTileProviders.isNotEmpty()) { - DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) { - customTileProviders.forEach { config -> - DropdownMenuItem( - text = { Text(config.name) }, - onClick = { - mapViewModel.selectCustomTileProvider(config) - onDismissRequest() - }, - trailingIcon = - if (selectedCustomUrl == config.urlTemplate) { - { - Icon( - MeshtasticIcons.Check, - contentDescription = stringResource(Res.string.selected_map_type), - ) - } - } else { - null - }, - ) - } - } - } - DropdownMenuItem( - text = { Text(stringResource(Res.string.manage_custom_tile_sources)) }, - onClick = { - onManageCustomTileProvidersClicked() - onDismissRequest() - }, - ) - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt deleted file mode 100644 index 6d38e176af..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt +++ /dev/null @@ -1,114 +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.component - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.currentStateAsState -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.savedstate.compose.LocalSavedStateRegistryOwner -import androidx.savedstate.findViewTreeSavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import com.google.maps.android.clustering.Cluster -import com.google.maps.android.clustering.view.DefaultClusterRenderer -import com.google.maps.android.compose.Circle -import com.google.maps.android.compose.MapsComposeExperimentalApi -import com.google.maps.android.compose.clustering.Clustering -import com.google.maps.android.compose.clustering.ClusteringMarkerProperties -import org.meshtastic.app.map.model.NodeClusterItem -import org.meshtastic.feature.map.BaseMapViewModel - -@OptIn(MapsComposeExperimentalApi::class) -@Suppress("NestedBlockDepth") -@Composable -fun NodeClusterMarkers( - nodeClusterItems: List, - mapFilterState: BaseMapViewModel.MapFilterState, - navigateToNodeDetails: (Int) -> Unit, - onClusterClick: (Cluster) -> Boolean, -) { - val view = LocalView.current - val lifecycleOwner = LocalLifecycleOwner.current - val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current - val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState() - - // maps-compose renders each non-clustered item to a bitmap through an off-screen ComposeView that - // it attaches under the MapView (see ComposeUiClusterRenderer + NoDrawContainerView in - // MapComposeViewRender). That ComposeView walks up the view tree for a ViewTreeLifecycleOwner and, - // when it finds none, crashes with "Composed into the View which doesn't propagate - // ViewTreeLifecycleOwner!" (googlemaps/android-maps-compose#875 / #325) — a FATAL on the map screen. - // - // Propagate the owners onto this map screen's host view (LocalView.current), which is an ancestor - // of the internally-created MapView, so the renderer's ComposeView can resolve them. We deliberately - // do NOT touch view.rootView (the activity root): attaching a transient NavEntry lifecycle there is - // what caused the node-list popup regression (#5684), which is why #5704 removed the prior, broader - // workaround entirely. Scoping to the map host view and restoring the previous owners on dispose - // keeps the fix local to the map and leaves Popups/DropdownMenus untouched. - DisposableEffect(view, lifecycleOwner, savedStateRegistryOwner) { - val prevLifecycleOwner = view.findViewTreeLifecycleOwner() - val prevSavedStateRegistryOwner = view.findViewTreeSavedStateRegistryOwner() - view.setViewTreeLifecycleOwner(lifecycleOwner) - view.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - onDispose { - view.setViewTreeLifecycleOwner(prevLifecycleOwner) - view.setViewTreeSavedStateRegistryOwner(prevSavedStateRegistryOwner) - } - } - - // The cluster renderer drives marker rendering from an async Handler (DefaultClusterRenderer's - // MarkerModifier), which can fire after this screen has stopped and the internal ComposeView is - // detached — at which point no owner is reachable regardless of the above. Skip rendering once the - // lifecycle is no longer at least STARTED to close most of that race. - if (!lifecycleState.isAtLeast(Lifecycle.State.STARTED)) return - - Clustering( - items = nodeClusterItems, - onClusterClick = onClusterClick, - onClusterItemInfoWindowClick = { item -> - navigateToNodeDetails(item.node.num) - false - }, - clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) }, - onClusterManager = { clusterManager -> - (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10 - }, - clusterItemDecoration = { clusterItem -> - if (mapFilterState.showPrecisionCircle) { - clusterItem.getPrecisionMeters()?.let { precisionMeters -> - if (precisionMeters > 0) { - Circle( - center = clusterItem.position, - radius = precisionMeters, - fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f), - strokeColor = Color(clusterItem.node.colors.second), - strokeWidth = 2f, - zIndex = 0f, - ) - } - } - } - // Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others) - ClusteringMarkerProperties(zIndex = clusterItem.getZIndex()) - }, - ) -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt deleted file mode 100644 index be20ad830c..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt +++ /dev/null @@ -1,68 +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.component - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.Node -import org.meshtastic.core.ui.component.NodeChip - -@Composable -fun PulsingNodeChip(node: Node, modifier: Modifier = Modifier) { - val animatedProgress = remember { Animatable(0f) } - - LaunchedEffect(node) { - if ((nowSeconds - node.lastHeard) <= 5) { - launch { - animatedProgress.snapTo(0f) - animatedProgress.animateTo( - targetValue = 1f, - animationSpec = tween(durationMillis = 1000, easing = LinearEasing), - ) - } - } - } - - Box( - modifier = - modifier.drawWithContent { - drawContent() - if (animatedProgress.value > 0 && animatedProgress.value < 1f) { - val alpha = (1f - animatedProgress.value) * 0.3f - drawRoundRect( - size = size, - cornerRadius = CornerRadius(8.dp.toPx()), - color = Color.White.copy(alpha = alpha), - ) - } - }, - ) { - NodeChip(node = node) - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt deleted file mode 100644 index ed1044fc2e..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt +++ /dev/null @@ -1,79 +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.component - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalContext -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 - -@Composable -fun WaypointMarkers( - displayableWaypoints: List, - mapFilterState: BaseMapViewModel.MapFilterState, - myNodeNum: Int, - isConnected: Boolean, - onEditWaypointRequest: (Waypoint) -> Unit, - selectedWaypointId: Int? = null, -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - if (mapFilterState.showWaypoints) { - displayableWaypoints.forEach { waypoint -> - val markerState = - rememberUpdatedMarkerState( - position = LatLng((waypoint.latitude_i ?: 0) * DEG_D, (waypoint.longitude_i ?: 0) * DEG_D), - ) - - LaunchedEffect(selectedWaypointId) { - if (selectedWaypointId == waypoint.id) { - markerState.showInfoWindow() - } - } - - val iconCodePoint = if (waypoint.icon == 0) PUSHPIN else waypoint.icon - val icon = rememberEmojiMarkerDescriptor(iconCodePoint) - - Marker( - state = markerState, - icon = icon, - title = waypoint.name.replace('\n', ' ').replace('\b', ' '), - snippet = waypoint.description.replace('\n', ' ').replace('\b', ' '), - visible = true, - onInfoWindowClick = { - if (waypoint.locked_to == 0 || waypoint.locked_to == myNodeNum || !isConnected) { - onEditWaypointRequest(waypoint) - } else { - scope.launch { context.showToast(Res.string.locked) } - } - }, - ) - } - } -} - -private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt deleted file mode 100644 index 5bd9fdf87f..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.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.app.map.model - -import kotlinx.serialization.Serializable -import kotlin.uuid.Uuid - -@Serializable -data class CustomTileProviderConfig( - val id: String = Uuid.random().toString(), - val name: String, - val urlTemplate: String, - val localUri: String? = null, -) { - val isLocal: Boolean - get() = localUri != null -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt deleted file mode 100644 index 14db01d4b1..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt +++ /dev/null @@ -1,26 +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.model - -class CustomTileSource { - - companion object { - fun getTileSource(index: Int) { - index - } - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt deleted file mode 100644 index c43dff841d..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt +++ /dev/null @@ -1,62 +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.model - -import com.google.android.gms.maps.model.LatLng -import com.google.maps.android.clustering.ClusterItem -import org.meshtastic.core.model.Node - -data class NodeClusterItem( - val node: Node, - val nodePosition: LatLng, - val nodeTitle: String, - val nodeSnippet: String, - val myNodeNum: Int? = null, -) : ClusterItem { - override fun getPosition(): LatLng = nodePosition - - override fun getTitle(): String = nodeTitle - - override fun getSnippet(): String = nodeSnippet - - override fun getZIndex(): Float = when { - node.num == myNodeNum -> 5.0f - - // My node is always highest - node.isFavorite -> 5.0f - - // Favorites are equally high priority - else -> 4.0f - } - - fun getPrecisionMeters(): Double? { - val precisionMap = - mapOf( - 10 to 23345.484932, - 11 to 11672.7369, - 12 to 5836.36288, - 13 to 2918.175876, - 14 to 1459.0823719999053, - 15 to 729.53562, - 16 to 364.7622, - 17 to 182.375556, - 18 to 91.182212, - 19 to 45.58554, - ) - return precisionMap[this.node.position.precision_bits] - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt deleted file mode 100644 index 2f7244b979..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ /dev/null @@ -1,58 +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.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). - * - * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected]. - */ -@Composable -fun NodeTrackMap( - destNum: Int, - positions: List, - modifier: Modifier = Modifier, - selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, -) { - val vm = koinViewModel() - vm.setDestNum(destNum) - val focusedNode by vm.node.collectAsStateWithLifecycle() - MapView( - modifier = modifier, - mode = - GoogleMapMode.NodeTrack( - focusedNode = focusedNode, - positions = positions, - selectedPositionTime = selectedPositionTime, - onPositionSelected = onPositionSelected, - ), - ) -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt deleted file mode 100644 index 668dedbaaa..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt +++ /dev/null @@ -1,45 +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.prefs.di - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.SharedPreferencesMigration -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStoreFile -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import org.koin.core.annotation.ComponentScan -import org.koin.core.annotation.Module -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.di.CoroutineDispatchers - -@Module -@ComponentScan("org.meshtastic.app.map") -class GoogleMapsKoinModule { - - @Single - @Named("GoogleMapsDataStore") - fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore = - PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")), - scope = CoroutineScope(dispatchers.io + SupervisorJob()), - produceFile = { context.preferencesDataStoreFile("google_maps_ds") }, - ) -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt deleted file mode 100644 index 2bb06406e4..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt +++ /dev/null @@ -1,196 +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.prefs.map - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.doublePreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.floatPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.core.stringSetPreferencesKey -import com.google.maps.android.compose.MapType -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.di.CoroutineDispatchers - -/** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */ -interface GoogleMapsPrefs { - val selectedGoogleMapType: StateFlow - - fun setSelectedGoogleMapType(value: String?) - - val selectedCustomTileUrl: StateFlow - - fun setSelectedCustomTileUrl(value: String?) - - val hiddenLayerUrls: StateFlow> - - fun setHiddenLayerUrls(value: Set) - - val cameraTargetLat: StateFlow - - fun setCameraTargetLat(value: Double) - - val cameraTargetLng: StateFlow - - fun setCameraTargetLng(value: Double) - - val cameraZoom: StateFlow - - fun setCameraZoom(value: Float) - - val cameraTilt: StateFlow - - fun setCameraTilt(value: Float) - - val cameraBearing: StateFlow - - fun setCameraBearing(value: Float) - - val networkMapLayers: StateFlow> - - fun setNetworkMapLayers(value: Set) -} - -@Single -class GoogleMapsPrefsImpl( - @Named("GoogleMapsDataStore") private val dataStore: DataStore, - dispatchers: CoroutineDispatchers, -) : GoogleMapsPrefs { - private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) - - override val selectedGoogleMapType: StateFlow = - dataStore.data - .map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name } - .stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name) - - override fun setSelectedGoogleMapType(value: String?) { - scope.launch { - dataStore.edit { prefs -> - if (value == null) { - prefs.remove(KEY_SELECTED_GOOGLE_MAP_TYPE_PREF) - } else { - prefs[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] = value - } - } - } - } - - override val selectedCustomTileUrl: StateFlow = - dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null) - - override fun setSelectedCustomTileUrl(value: String?) { - scope.launch { - dataStore.edit { prefs -> - if (value == null) { - prefs.remove(KEY_SELECTED_CUSTOM_TILE_URL_PREF) - } else { - prefs[KEY_SELECTED_CUSTOM_TILE_URL_PREF] = value - } - } - } - } - - override val hiddenLayerUrls: StateFlow> = - dataStore.data - .map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() } - .stateIn(scope, SharingStarted.Eagerly, emptySet()) - - override fun setHiddenLayerUrls(value: Set) { - scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } } - } - - override val cameraTargetLat: StateFlow = - dataStore.data - .map { - try { - it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0 - } catch (_: ClassCastException) { - it[floatPreferencesKey(KEY_CAMERA_TARGET_LAT_PREF.name)]?.toDouble() ?: 0.0 - } - } - .stateIn(scope, SharingStarted.Eagerly, 0.0) - - override fun setCameraTargetLat(value: Double) { - scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } } - } - - override val cameraTargetLng: StateFlow = - dataStore.data - .map { - try { - it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0 - } catch (_: ClassCastException) { - it[floatPreferencesKey(KEY_CAMERA_TARGET_LNG_PREF.name)]?.toDouble() ?: 0.0 - } - } - .stateIn(scope, SharingStarted.Eagerly, 0.0) - - override fun setCameraTargetLng(value: Double) { - scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } } - } - - override val cameraZoom: StateFlow = - dataStore.data.map { it[KEY_CAMERA_ZOOM_PREF] ?: 7f }.stateIn(scope, SharingStarted.Eagerly, 7f) - - override fun setCameraZoom(value: Float) { - scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM_PREF] = value } } - } - - override val cameraTilt: StateFlow = - dataStore.data.map { it[KEY_CAMERA_TILT_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f) - - override fun setCameraTilt(value: Float) { - scope.launch { dataStore.edit { it[KEY_CAMERA_TILT_PREF] = value } } - } - - override val cameraBearing: StateFlow = - dataStore.data.map { it[KEY_CAMERA_BEARING_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f) - - override fun setCameraBearing(value: Float) { - scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } } - } - - override val networkMapLayers: StateFlow> = - dataStore.data - .map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() } - .stateIn(scope, SharingStarted.Eagerly, emptySet()) - - override fun setNetworkMapLayers(value: Set) { - scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } } - } - - companion object { - val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type") - val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url") - val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls") - val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat") - val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng") - val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom") - val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt") - val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing") - val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers") - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt deleted file mode 100644 index 48d89d258c..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt +++ /dev/null @@ -1,104 +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.repository - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import org.koin.core.annotation.Single -import org.meshtastic.app.map.model.CustomTileProviderConfig -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.repository.MapTileProviderPrefs - -interface CustomTileProviderRepository { - fun getCustomTileProviders(): Flow> - - suspend fun addCustomTileProvider(config: CustomTileProviderConfig) - - suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) - - suspend fun deleteCustomTileProvider(configId: String) - - suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? -} - -@Single -class CustomTileProviderRepositoryImpl( - private val json: Json, - private val dispatchers: CoroutineDispatchers, - private val mapTileProviderPrefs: MapTileProviderPrefs, -) : CustomTileProviderRepository { - - private val customTileProvidersStateFlow = MutableStateFlow>(emptyList()) - - init { - loadDataFromPrefs() - } - - override fun getCustomTileProviders(): Flow> = - customTileProvidersStateFlow.asStateFlow() - - override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) { - val newList = customTileProvidersStateFlow.value + config - customTileProvidersStateFlow.value = newList - saveDataToPrefs(newList) - } - - override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) { - val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it } - customTileProvidersStateFlow.value = newList - saveDataToPrefs(newList) - } - - override suspend fun deleteCustomTileProvider(configId: String) { - val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId } - customTileProvidersStateFlow.value = newList - saveDataToPrefs(newList) - } - - override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? = - customTileProvidersStateFlow.value.find { it.id == configId } - - private fun loadDataFromPrefs() { - val jsonString = mapTileProviderPrefs.customTileProviders.value - if (jsonString != null) { - try { - customTileProvidersStateFlow.value = json.decodeFromString>(jsonString) - } catch (e: SerializationException) { - Logger.e(e) { "Error deserializing tile providers" } - customTileProvidersStateFlow.value = emptyList() - } - } else { - customTileProvidersStateFlow.value = emptyList() - } - } - - private suspend fun saveDataToPrefs(providers: List) { - withContext(dispatchers.io) { - try { - val jsonString = json.encodeToString(providers) - mapTileProviderPrefs.setCustomTileProviders(jsonString) - } catch (e: SerializationException) { - Logger.e(e) { "Error serializing tile providers" } - } - } - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt deleted file mode 100644 index d725537c82..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt +++ /dev/null @@ -1,46 +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.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/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt deleted file mode 100644 index f77f8aec18..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt +++ /dev/null @@ -1,84 +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.node.component - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import com.google.android.gms.maps.model.CameraPosition -import com.google.android.gms.maps.model.LatLng -import com.google.maps.android.compose.Circle -import com.google.maps.android.compose.ComposeMapColorScheme -import com.google.maps.android.compose.GoogleMap -import com.google.maps.android.compose.MapUiSettings -import com.google.maps.android.compose.Marker -import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberUpdatedMarkerState -import org.meshtastic.app.map.component.rememberNodeChipDescriptor -import org.meshtastic.core.model.Node -import org.meshtastic.core.ui.component.precisionBitsToMeters - -private const val DEFAULT_ZOOM = 15f - -@Composable -fun InlineMap(node: Node, modifier: Modifier = Modifier) { - val dark = isSystemInDarkTheme() - val mapColorScheme = - when (dark) { - true -> ComposeMapColorScheme.DARK - else -> ComposeMapColorScheme.LIGHT - } - key(node.num) { - val location = LatLng(node.latitude, node.longitude) - val cameraState = rememberCameraPositionState { - position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM) - } - val markerIcon = rememberNodeChipDescriptor(node) - - GoogleMap( - mapColorScheme = mapColorScheme, - modifier = modifier, - uiSettings = - MapUiSettings( - zoomControlsEnabled = true, - mapToolbarEnabled = false, - compassEnabled = false, - myLocationButtonEnabled = false, - rotationGesturesEnabled = false, - scrollGesturesEnabled = false, - tiltGesturesEnabled = false, - zoomGesturesEnabled = false, - ), - cameraPositionState = cameraState, - ) { - val precisionMeters = precisionBitsToMeters(node.position.precision_bits) - val latLng = LatLng(node.latitude, node.longitude) - if (precisionMeters > 0) { - Circle( - center = latLng, - radius = precisionMeters, - fillColor = Color(node.colors.second).copy(alpha = 0.2f), - strokeColor = Color(node.colors.second), - strokeWidth = 2f, - ) - } - Marker(state = rememberUpdatedMarkerState(position = latLng), icon = markerIcon) - } - } -} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt deleted file mode 100644 index 64224501a5..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt +++ /dev/null @@ -1,28 +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.node.metrics - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.dp -import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets - -fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets( - overlayAlignment = Alignment.BottomCenter, - overlayPadding = PaddingValues(bottom = 16.dp), - contentHorizontalAlignment = Alignment.CenterHorizontally, -) diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 78e8ce5592..8b30c8d7fe 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -53,9 +53,6 @@ import org.koin.androidx.viewmodel.ext.android.viewModel 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 -import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets import org.meshtastic.app.ui.MainScreen import org.meshtastic.core.barcode.rememberBarcodeScanner import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI @@ -70,25 +67,12 @@ 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.LocalEventBranding -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 : AppCompatActivity() { private val model: UIViewModel by viewModel() @@ -189,56 +173,6 @@ class MainActivity : AppCompatActivity() { LocalBarcodeScannerSupported provides true, LocalNfcScannerSupported provides true, LocalAnalyticsIntroProvider provides { AnalyticsIntro() }, - LocalMapViewProvider provides getMapViewProvider(), - LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) }, - LocalNodeTrackMapProvider provides - { destNum, positions, modifier, selectedPositionTime, onPositionSelected -> - org.meshtastic.app.map.node.NodeTrackMap( - destNum, - positions, - modifier, - selectedPositionTime, - onPositionSelected, - ) - }, - LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(), - 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() - 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 -> - val viewModel = koinViewModel() - MapScreen( - viewModel = viewModel, - onClickNodeChip = onClickNodeChip, - navigateToNodeDetails = navigateToNodeDetails, - waypointId = waypointId, - ) - }, content = content, ) } diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt index 0aa9d2f414..01baaa991b 100644 --- a/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt +++ b/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt @@ -29,10 +29,10 @@ import org.koin.plugin.module.dsl.koinApplication import org.koin.test.verify.definition import org.koin.test.verify.injectedParameters import org.koin.test.verify.verify -import org.meshtastic.app.map.MapViewModel import org.meshtastic.core.ble.BleLogFormat import org.meshtastic.core.ble.BleLogLevel import org.meshtastic.core.model.util.NodeIdLookup +import org.meshtastic.feature.map.MapViewModel import org.meshtastic.feature.node.metrics.MetricsViewModel import kotlin.test.Test diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt new file mode 100644 index 0000000000..b7f27080bc --- /dev/null +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt @@ -0,0 +1,126 @@ +/* + * 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.prefs.map + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.MapCameraPrefs + +@Single +class MapCameraPrefsImpl( + @Named("MapDataStore") private val dataStore: DataStore, + dispatchers: CoroutineDispatchers, +) : MapCameraPrefs { + private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) + + override val cameraLat: StateFlow = + dataStore.data.map { it[KEY_CAMERA_LAT] ?: DEFAULT_LAT }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_LAT) + + override fun setCameraLat(value: Double) { + scope.launch { dataStore.edit { it[KEY_CAMERA_LAT] = value } } + } + + override val cameraLng: StateFlow = + dataStore.data.map { it[KEY_CAMERA_LNG] ?: DEFAULT_LNG }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_LNG) + + override fun setCameraLng(value: Double) { + scope.launch { dataStore.edit { it[KEY_CAMERA_LNG] = value } } + } + + override val cameraZoom: StateFlow = + dataStore.data.map { it[KEY_CAMERA_ZOOM] ?: DEFAULT_ZOOM }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_ZOOM) + + override fun setCameraZoom(value: Float) { + scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM] = value } } + } + + override val cameraTilt: StateFlow = + dataStore.data.map { it[KEY_CAMERA_TILT] ?: DEFAULT_TILT }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_TILT) + + override fun setCameraTilt(value: Float) { + scope.launch { dataStore.edit { it[KEY_CAMERA_TILT] = value } } + } + + override val cameraBearing: StateFlow = + dataStore.data + .map { it[KEY_CAMERA_BEARING] ?: DEFAULT_BEARING } + .stateIn(scope, SharingStarted.Eagerly, DEFAULT_BEARING) + + override fun setCameraBearing(value: Float) { + scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING] = value } } + } + + override val selectedStyleUri: StateFlow = + dataStore.data + .map { it[KEY_SELECTED_STYLE_URI] ?: DEFAULT_STYLE_URI } + .stateIn(scope, SharingStarted.Eagerly, DEFAULT_STYLE_URI) + + override fun setSelectedStyleUri(value: String) { + scope.launch { dataStore.edit { it[KEY_SELECTED_STYLE_URI] = value } } + } + + override val hiddenLayerUrls: StateFlow> = + dataStore.data + .map { it[KEY_HIDDEN_LAYER_URLS] ?: emptySet() } + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + override fun setHiddenLayerUrls(value: Set) { + scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS] = value } } + } + + override val networkMapLayers: StateFlow> = + dataStore.data + .map { it[KEY_NETWORK_MAP_LAYERS] ?: emptySet() } + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + override fun setNetworkMapLayers(value: Set) { + scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS] = value } } + } + + companion object { + private const val DEFAULT_LAT = 0.0 + private const val DEFAULT_LNG = 0.0 + private const val DEFAULT_ZOOM = 7f + private const val DEFAULT_TILT = 0f + private const val DEFAULT_BEARING = 0f + private const val DEFAULT_STYLE_URI = "" + + val KEY_CAMERA_LAT = doublePreferencesKey("map_camera_lat") + val KEY_CAMERA_LNG = doublePreferencesKey("map_camera_lng") + val KEY_CAMERA_ZOOM = floatPreferencesKey("map_camera_zoom") + val KEY_CAMERA_TILT = floatPreferencesKey("map_camera_tilt") + val KEY_CAMERA_BEARING = floatPreferencesKey("map_camera_bearing") + val KEY_SELECTED_STYLE_URI = stringPreferencesKey("map_selected_style_uri") + val KEY_HIDDEN_LAYER_URLS = stringSetPreferencesKey("map_hidden_layer_urls") + val KEY_NETWORK_MAP_LAYERS = stringSetPreferencesKey("map_network_layers") + } +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index 1e0cbd29d7..2dffbf073f 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -242,6 +242,41 @@ interface MapPrefs { fun setLastHeardTrackFilter(seconds: Long) } +/** Reactive interface for map camera position persistence. */ +interface MapCameraPrefs { + val cameraLat: StateFlow + + fun setCameraLat(value: Double) + + val cameraLng: StateFlow + + fun setCameraLng(value: Double) + + val cameraZoom: StateFlow + + fun setCameraZoom(value: Float) + + val cameraTilt: StateFlow + + fun setCameraTilt(value: Float) + + val cameraBearing: StateFlow + + fun setCameraBearing(value: Float) + + val selectedStyleUri: StateFlow + + fun setSelectedStyleUri(value: String) + + val hiddenLayerUrls: StateFlow> + + fun setHiddenLayerUrls(value: Set) + + val networkMapLayers: StateFlow> + + fun setNetworkMapLayers(value: Set) +} + /** Reactive interface for map consent. */ interface MapConsentPrefs { fun shouldReportLocation(nodeNum: Int?): StateFlow @@ -305,6 +340,7 @@ interface AppPreferences { val emoji: CustomEmojiPrefs val ui: UiPrefs val map: MapPrefs + val mapCamera: MapCameraPrefs val mapConsent: MapConsentPrefs val mapTileProvider: MapTileProviderPrefs val radio: RadioPrefs diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 6c59d355d7..5b1a8bc3ff 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1248,6 +1248,47 @@ RX Boosted Gain System Settings + Generate QR Code + NFC is disabled. Please enable it in system settings. + All + + Bluetooth + Configure Bluetooth Permissions + Discovery + Find and identify Meshtastic devices near you. + Configuration + Wirelessly manage your device settings and channels. + Map style selection + OpenStreetMap + Satellite + Terrain + Hybrid + Dark + + Battery: %1$d% + Nodes: %1$d online / %2$d total + Uptime: %1$s + ChUtil: %1$s% | AirTX: %2$s% + Traffic: TX %1$d / RX %2$d (D: %3$d) + Relays: %1$d (Canceled: %2$d) + Diagnostics: %1$s + Noise %1$d dBm + Bad %1$d + Dropped %1$d + Heap + %1$d / %2$d + %1$s + Powered + Refresh + Updated + + + Add Network Layer + https://example.com/map.kml or .geojson + + Local MBTiles File + Add Local MBTiles File + TAK (ATAK) TAK Configuration Member Role diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index bef3d2be13..8632281515 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -23,6 +23,7 @@ import org.meshtastic.core.repository.AppPreferences import org.meshtastic.core.repository.CustomEmojiPrefs import org.meshtastic.core.repository.FilterPrefs import org.meshtastic.core.repository.HomoglyphPrefs +import org.meshtastic.core.repository.MapCameraPrefs import org.meshtastic.core.repository.MapConsentPrefs import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.MapTileProviderPrefs @@ -287,6 +288,56 @@ class FakeMapPrefs : MapPrefs { } } +class FakeMapCameraPrefs : MapCameraPrefs { + override val cameraLat = MutableStateFlow(0.0) + + override fun setCameraLat(value: Double) { + cameraLat.value = value + } + + override val cameraLng = MutableStateFlow(0.0) + + override fun setCameraLng(value: Double) { + cameraLng.value = value + } + + override val cameraZoom = MutableStateFlow(7f) + + override fun setCameraZoom(value: Float) { + cameraZoom.value = value + } + + override val cameraTilt = MutableStateFlow(0f) + + override fun setCameraTilt(value: Float) { + cameraTilt.value = value + } + + override val cameraBearing = MutableStateFlow(0f) + + override fun setCameraBearing(value: Float) { + cameraBearing.value = value + } + + override val selectedStyleUri = MutableStateFlow("") + + override fun setSelectedStyleUri(value: String) { + selectedStyleUri.value = value + } + + override val hiddenLayerUrls = MutableStateFlow(emptySet()) + + override fun setHiddenLayerUrls(value: Set) { + hiddenLayerUrls.value = value + } + + override val networkMapLayers = MutableStateFlow(emptySet()) + + override fun setNetworkMapLayers(value: Set) { + networkMapLayers.value = value + } +} + class FakeMapConsentPrefs : MapConsentPrefs { private val consent = mutableMapOf>() @@ -344,6 +395,7 @@ class FakeAppPreferences : AppPreferences { override val emoji = FakeCustomEmojiPrefs() override val ui = FakeUiPrefs() override val map = FakeMapPrefs() + override val mapCamera = FakeMapCameraPrefs() override val mapConsent = FakeMapConsentPrefs() override val mapTileProvider = FakeMapTileProviderPrefs() override val radio = FakeRadioPrefs() diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt deleted file mode 100644 index e2a3206d11..0000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt +++ /dev/null @@ -1,24 +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 androidx.compose.ui.Modifier -import org.meshtastic.core.model.Node - -val LocalInlineMapProvider = compositionLocalOf<@Composable (node: Node, modifier: Modifier) -> Unit> { { _, _ -> } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt deleted file mode 100644 index 70ed07a2b0..0000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt +++ /dev/null @@ -1,33 +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 Main Screen. On Desktop or JVM targets where native maps aren't available yet, it - * falls back to a [PlaceholderScreen]. - */ -@Suppress("Wrapping") -val LocalMapMainScreenProvider = - compositionLocalOf< - @Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit, - > { - { _, _, _ -> PlaceholderScreen("Map") } - } 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 deleted file mode 100644 index d0901f0f9d..0000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt +++ /dev/null @@ -1,50 +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 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). - * - * Supports optional synchronized selection: - * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When - * non-null, the map should visually highlight the corresponding marker and center the camera on it. - * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so - * the host can synchronize the card list. - * - * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. - */ -@Suppress("Wrapping") -val LocalNodeTrackMapProvider = - compositionLocalOf< - @Composable ( - destNum: Int, - positions: List, - modifier: Modifier, - selectedPositionTime: Int?, - onPositionSelected: ((Int) -> Unit)?, - ) -> Unit, - > { - { _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } - } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt deleted file mode 100644 index 40b174e8db..0000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt +++ /dev/null @@ -1,30 +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.foundation.layout.PaddingValues -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.dp - -data class TracerouteMapOverlayInsets( - val overlayAlignment: Alignment = Alignment.BottomCenter, - val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp), - val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, -) - -val LocalTracerouteMapOverlayInsetsProvider = compositionLocalOf { TracerouteMapOverlayInsets() } 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 deleted file mode 100644 index 139992c540..0000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt +++ /dev/null @@ -1,51 +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 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/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/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 deleted file mode 100644 index 10d975f3d4..0000000000 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.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 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). Platform implementations create their own ViewModel via Koin. - */ -interface MapViewProvider { - @Composable fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int? = null) -} - -val LocalMapViewProvider = compositionLocalOf { null } diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index 2830fed6ee..525cb12fc8 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -25,6 +25,8 @@ kotlin { sourceSets { commonMain.dependencies { implementation(libs.kotlinx.collections.immutable) + api(libs.maplibre.compose) + implementation(libs.maplibre.compose.material3) implementation(projects.core.data) implementation(projects.core.database) implementation(projects.core.datastore) 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 deleted file mode 100644 index ccbfe6b5d0..0000000000 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ /dev/null @@ -1,64 +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.feature.map - -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.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.map -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.util.LocalMapViewProvider - -@Composable -fun MapScreen( - onClickNodeChip: (Int) -> Unit, - navigateToNodeDetails: (Int) -> Unit, - modifier: Modifier = Modifier, - viewModel: SharedMapViewModel, - waypointId: Int? = null, -) { - val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() - - @Suppress("ViewModelForwarding") - Scaffold( - modifier = modifier, - topBar = { - MainAppBar( - title = stringResource(Res.string.map), - ourNode = ourNodeInfo, - showNodeChip = ourNodeInfo != null && isConnected, - canNavigateUp = false, - onNavigateUp = {}, - actions = {}, - onClickChip = { onClickNodeChip(it.num) }, - ) - }, - ) { paddingValues -> - LocalMapViewProvider.current?.MapView( - modifier = Modifier.fillMaxSize().padding(paddingValues), - navigateToNodeDetails = navigateToNodeDetails, - waypointId = waypointId, - ) - } -} diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt deleted file mode 100644 index 7fc3ba4c6f..0000000000 --- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt +++ /dev/null @@ -1,63 +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.feature.map - -import android.database.sqlite.SQLiteDatabase -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import java.io.File -import kotlin.test.assertEquals - -@RunWith(RobolectricTestRunner::class) -class MBTilesProviderTest { - - @get:Rule val tempFolder = TemporaryFolder() - - @Test - fun `getTile translates y coordinate correctly to TMS`() { - val dbFile = tempFolder.newFile("test.mbtiles") - setupMockDatabase(dbFile) - - val provider = MBTilesProvider(dbFile) - - // Google Maps zoom 1, x=0, y=0 - // TMS y = (1 << 1) - 1 - 0 = 1 - provider.getTile(0, 0, 1) - - // We verify the query was correct by checking the database if we could, - // but here we just ensure it doesn't crash and returns the expected No Tile if missing. - // To truly test, we'd need to insert data. - - val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE) - db.execSQL("INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (1, 0, 1, x'1234')") - db.close() - - val tile = provider.getTile(0, 0, 1) - assertEquals(256, tile?.width) - assertEquals(256, tile?.height) - // Robolectric SQLite might return different blob handling, but let's see. - } - - private fun setupMockDatabase(file: File) { - val db = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY) - db.execSQL("CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB)") - db.close() - } -} diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt deleted file mode 100644 index d665bff2dd..0000000000 --- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ /dev/null @@ -1,154 +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.feature.map - -import android.app.Application -import androidx.lifecycle.SavedStateHandle -import com.google.android.gms.maps.model.UrlTileProvider -import dev.mokkery.MockMode -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.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.testing.FakeNodeRepository -import org.meshtastic.core.testing.FakeRadioController -import org.meshtastic.feature.map.model.CustomTileProviderConfig -import org.meshtastic.feature.map.prefs.map.GoogleMapsPrefs -import org.meshtastic.feature.map.repository.CustomTileProviderRepository -import org.robolectric.RobolectricTestRunner -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -class MapViewModelTest { - - private val application = mock(MockMode.autofill) - private val mapPrefs = mock(MockMode.autofill) - private val googleMapsPrefs = mock(MockMode.autofill) - private val nodeRepository = FakeNodeRepository() - private val packetRepository = mock(MockMode.autofill) - private val radioConfigRepository = mock(MockMode.autofill) - private val radioController = FakeRadioController() - private val customTileProviderRepository = mock(MockMode.autofill) - private val uiPrefs = mock(MockMode.autofill) - private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null)) - - private val testDispatcher = StandardTestDispatcher() - - private lateinit var viewModel: MapViewModel - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - every { mapPrefs.mapStyle } returns MutableStateFlow(0) - every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false) - every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(true) - every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(true) - every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L) - every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L) - - every { googleMapsPrefs.cameraTargetLat } returns MutableStateFlow(0.0) - every { googleMapsPrefs.cameraTargetLng } returns MutableStateFlow(0.0) - every { googleMapsPrefs.cameraZoom } returns MutableStateFlow(0f) - every { googleMapsPrefs.cameraTilt } returns MutableStateFlow(0f) - every { googleMapsPrefs.cameraBearing } returns MutableStateFlow(0f) - every { googleMapsPrefs.selectedCustomTileUrl } returns MutableStateFlow(null) - every { googleMapsPrefs.selectedGoogleMapType } returns MutableStateFlow(null) - every { googleMapsPrefs.hiddenLayerUrls } returns MutableStateFlow(emptySet()) - - every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList()) - every { radioConfigRepository.deviceProfileFlow } returns flowOf(mock(MockMode.autofill)) - every { uiPrefs.theme } returns MutableStateFlow(1) - every { packetRepository.getWaypoints() } returns flowOf(emptyList()) - - viewModel = - MapViewModel( - application, - mapPrefs, - googleMapsPrefs, - nodeRepository, - packetRepository, - radioConfigRepository, - radioController, - customTileProviderRepository, - uiPrefs, - savedStateHandle, - ) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `getTileProvider returns UrlTileProvider for remote config`() = runTest { - val config = - CustomTileProviderConfig( - name = "OpenStreetMap", - urlTemplate = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - ) - - val provider = viewModel.getTileProvider(config) - assertTrue(provider is UrlTileProvider) - } - - @Test - fun `addNetworkMapLayer detects GeoJSON based on extension`() = runTest(testDispatcher) { - viewModel.addNetworkMapLayer("Test Layer", "https://example.com/data.geojson") - advanceUntilIdle() - - val layer = viewModel.mapLayers.value.find { it.name == "Test Layer" } - assertEquals(LayerType.GEOJSON, layer?.layerType) - } - - @Test - fun `addNetworkMapLayer defaults to KML for other extensions`() = runTest(testDispatcher) { - viewModel.addNetworkMapLayer("Test KML", "https://example.com/map.kml") - advanceUntilIdle() - - val layer = viewModel.mapLayers.value.find { it.name == "Test KML" } - assertEquals(LayerType.KML, layer?.layerType) - } - - @Test - fun `setWaypointId updates value correctly including null`() = runTest(testDispatcher) { - // Set to a valid ID - viewModel.setWaypointId(123) - assertEquals(123, viewModel.selectedWaypointId.value) - - // Set to null should clear the selection - viewModel.setWaypointId(null) - assertEquals(null, viewModel.selectedWaypointId.value) - } -} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt new file mode 100644 index 0000000000..92291d3f3f --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -0,0 +1,105 @@ +/* + * 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.feature.map + +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.maplibre.compose.camera.rememberCameraState +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.map +import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.feature.map.component.MapControlsOverlay +import org.meshtastic.feature.map.component.MaplibreMapContent + +/** + * Main map screen composable. Uses MapLibre Compose Multiplatform to render an interactive map with mesh node markers, + * waypoints, and overlays. + * + * This replaces the previous flavor-specific Google Maps and OSMDroid implementations with a single cross-platform + * composable. + */ +@Composable +fun MapScreen( + onClickNodeChip: (Int) -> Unit, + navigateToNodeDetails: (Int) -> Unit, + modifier: Modifier = Modifier, + viewModel: MapViewModel, + waypointId: Int? = null, +) { + val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle() + val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() + val nodesWithPosition by viewModel.nodesWithPosition.collectAsStateWithLifecycle() + val waypoints by viewModel.waypoints.collectAsStateWithLifecycle() + val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle() + val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle() + + LaunchedEffect(waypointId) { viewModel.setWaypointId(waypointId) } + + val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition) + + @Suppress("ViewModelForwarding") + Scaffold( + modifier = modifier, + topBar = { + MainAppBar( + title = stringResource(Res.string.map), + ourNode = ourNodeInfo, + showNodeChip = ourNodeInfo != null && isConnected, + canNavigateUp = false, + onNavigateUp = {}, + actions = {}, + onClickChip = { onClickNodeChip(it.num) }, + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + MaplibreMapContent( + nodes = nodesWithPosition, + waypoints = waypoints, + baseStyle = baseStyle, + cameraState = cameraState, + myNodeNum = viewModel.myNodeNum, + showWaypoints = filterState.showWaypoints, + showPrecisionCircle = filterState.showPrecisionCircle, + onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) }, + onMapLongClick = { position -> + // TODO: open waypoint creation dialog at position + }, + modifier = Modifier.fillMaxSize(), + onCameraMoved = { position -> viewModel.saveCameraPosition(position) }, + ) + + MapControlsOverlay( + onToggleFilterMenu = {}, + modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues), + bearing = cameraState.position.bearing.toFloat(), + onCompassClick = {}, + isLocationTrackingEnabled = false, + onToggleLocationTracking = {}, + ) + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt new file mode 100644 index 0000000000..f055021274 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -0,0 +1,100 @@ +/* + * 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 androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.KoinViewModel +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.style.BaseStyle +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.MapCameraPrefs +import org.meshtastic.core.repository.MapPrefs +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed +import org.meshtastic.feature.map.model.MapStyle +import org.maplibre.spatialk.geojson.Position as GeoPosition + +/** + * Unified map ViewModel replacing the previous Google and F-Droid flavor-specific ViewModels. + * + * Manages camera state persistence, map style selection, and waypoint selection using MapLibre Compose Multiplatform + * types. All map-related state is shared across platforms. + */ +@KoinViewModel +class MapViewModel( + mapPrefs: MapPrefs, + private val mapCameraPrefs: MapCameraPrefs, + nodeRepository: NodeRepository, + packetRepository: PacketRepository, + radioController: RadioController, + savedStateHandle: SavedStateHandle, +) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { + + /** Currently selected waypoint to focus on map. */ + private val selectedWaypointIdInternal = MutableStateFlow(savedStateHandle.get("waypointId")) + val selectedWaypointId: StateFlow = selectedWaypointIdInternal.asStateFlow() + + fun setWaypointId(id: Int?) { + selectedWaypointIdInternal.value = id + } + + /** Initial camera position restored from persistent preferences. */ + val initialCameraPosition: CameraPosition + get() = + CameraPosition( + target = + GeoPosition(longitude = mapCameraPrefs.cameraLng.value, latitude = mapCameraPrefs.cameraLat.value), + zoom = mapCameraPrefs.cameraZoom.value.toDouble(), + tilt = mapCameraPrefs.cameraTilt.value.toDouble(), + bearing = mapCameraPrefs.cameraBearing.value.toDouble(), + ) + + /** Active map base style. */ + val baseStyle: StateFlow = + mapCameraPrefs.selectedStyleUri + .map { uri -> if (uri.isBlank()) MapStyle.OpenStreetMap.toBaseStyle() else BaseStyle.Uri(uri) } + .stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle()) + + /** Currently selected map style enum index. */ + val selectedMapStyle: StateFlow = + mapCameraPrefs.selectedStyleUri + .map { uri -> MapStyle.entries.find { it.styleUri == uri } ?: MapStyle.OpenStreetMap } + .stateInWhileSubscribed(MapStyle.OpenStreetMap) + + /** Persist camera position to DataStore. */ + fun saveCameraPosition(position: CameraPosition) { + mapCameraPrefs.setCameraLat(position.target.latitude) + mapCameraPrefs.setCameraLng(position.target.longitude) + mapCameraPrefs.setCameraZoom(position.zoom.toFloat()) + mapCameraPrefs.setCameraTilt(position.tilt.toFloat()) + mapCameraPrefs.setCameraBearing(position.bearing.toFloat()) + } + + /** Select a predefined map style. */ + fun selectMapStyle(style: MapStyle) { + mapCameraPrefs.setSelectedStyleUri(style.styleUri) + } + + /** Bearing for the compass in degrees. */ + val compassBearing: Float + get() = mapCameraPrefs.cameraBearing.value +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt deleted file mode 100644 index bcebdabf62..0000000000 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.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.feature.map - -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MapPrefs -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketRepository - -@KoinViewModel -class SharedMapViewModel( - mapPrefs: MapPrefs, - nodeRepository: NodeRepository, - packetRepository: PacketRepository, - radioController: RadioController, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt new file mode 100644 index 0000000000..7e9cfa2cd0 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -0,0 +1,106 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.map.OrnamentOptions +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.style.BaseStyle +import org.maplibre.spatialk.geojson.Feature +import org.maplibre.spatialk.geojson.FeatureCollection +import org.maplibre.spatialk.geojson.Point +import org.meshtastic.core.model.Node +import org.meshtastic.feature.map.util.precisionBitsToMeters +import org.maplibre.spatialk.geojson.Position as GeoPosition + +private const val DEFAULT_ZOOM = 15.0 +private const val COORDINATE_SCALE = 1e-7 +private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f +private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f + +/** + * A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the + * Google Maps and OSMDroid inline map implementations. + */ +@Composable +fun InlineMap(node: Node, modifier: Modifier = Modifier) { + val position = node.validPosition ?: return + val lat = (position.latitude_i ?: 0) * COORDINATE_SCALE + val lng = (position.longitude_i ?: 0) * COORDINATE_SCALE + if (lat == 0.0 && lng == 0.0) return + + key(node.num) { + val cameraState = + rememberCameraState( + firstPosition = + CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = DEFAULT_ZOOM), + ) + + val nodeFeature = + remember(node.num, lat, lng) { + FeatureCollection( + listOf(Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = null)), + ) + } + + MaplibreMap( + modifier = modifier, + baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/liberty"), + cameraState = cameraState, + options = + MapOptions(gestureOptions = GestureOptions.AllDisabled, ornamentOptions = OrnamentOptions.AllDisabled), + ) { + val source = rememberGeoJsonSource(data = GeoJsonData.Features(nodeFeature)) + + // Node marker dot + CircleLayer( + id = "inline-node-marker", + source = source, + radius = const(8.dp), + color = const(Color(node.colors.second)), + strokeWidth = const(2.dp), + strokeColor = const(Color.White), + ) + + // Precision circle + val precisionMeters = precisionBitsToMeters(position.precision_bits ?: 0) + if (precisionMeters > 0) { + CircleLayer( + id = "inline-node-precision", + source = source, + radius = const(40.dp), // visual approximation + color = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), + strokeWidth = const(1.dp), + strokeColor = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)), + ) + } + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt new file mode 100644 index 0000000000..19e65518b5 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -0,0 +1,205 @@ +/* + * 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.component + +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 androidx.compose.ui.unit.em +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.expressions.dsl.asString +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.not +import org.maplibre.compose.expressions.dsl.offset +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.GeoJsonOptions +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.style.BaseStyle +import org.maplibre.compose.util.ClickResult +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.feature.map.util.nodesToFeatureCollection +import org.meshtastic.feature.map.util.waypointsToFeatureCollection +import org.maplibre.spatialk.geojson.Position as GeoPosition + +private val NodeMarkerColor = Color(0xFF6750A4) +private const val CLUSTER_RADIUS = 50 +private const val CLUSTER_MIN_POINTS = 10 +private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f +private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f +private const val CLUSTER_OPACITY = 0.85f + +/** + * Main map content composable using MapLibre Compose Multiplatform. + * + * Renders nodes as clustered markers, waypoints, and optional overlays (position tracks, traceroute routes). Replaces + * both the Google Maps and OSMDroid implementations with a single cross-platform composable. + */ +@Composable +fun MaplibreMapContent( + nodes: List, + waypoints: Map, + baseStyle: BaseStyle, + cameraState: CameraState, + myNodeNum: Int?, + showWaypoints: Boolean, + showPrecisionCircle: Boolean, + onNodeClick: (Int) -> Unit, + onMapLongClick: (GeoPosition) -> Unit, + modifier: Modifier = Modifier, + onCameraMoved: (CameraPosition) -> Unit = {}, +) { + MaplibreMap( + modifier = modifier, + baseStyle = baseStyle, + cameraState = cameraState, + onMapLongClick = { position, _ -> + onMapLongClick(position) + ClickResult.Consume + }, + onFrame = {}, + ) { + // --- Node markers with clustering --- + NodeMarkerLayers( + nodes = nodes, + myNodeNum = myNodeNum, + showPrecisionCircle = showPrecisionCircle, + onNodeClick = onNodeClick, + ) + + // --- Waypoint markers --- + if (showWaypoints) { + WaypointMarkerLayers(waypoints = waypoints) + } + } + + // Persist camera position when it stops moving + LaunchedEffect(cameraState.isCameraMoving) { + if (!cameraState.isCameraMoving) { + onCameraMoved(cameraState.position) + } + } +} + +/** Node markers rendered as clustered circles and symbols using GeoJSON source. */ +@Composable +private fun NodeMarkerLayers( + nodes: List, + myNodeNum: Int?, + showPrecisionCircle: Boolean, + onNodeClick: (Int) -> Unit, +) { + val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) } + + val nodesSource = + rememberGeoJsonSource( + data = GeoJsonData.Features(featureCollection), + options = + GeoJsonOptions(cluster = true, clusterRadius = CLUSTER_RADIUS, clusterMinPoints = CLUSTER_MIN_POINTS), + ) + + // Cluster circles + CircleLayer( + id = "node-clusters", + source = nodesSource, + filter = feature.has("cluster"), + radius = const(20.dp), + color = const(NodeMarkerColor), // Material primary + opacity = const(CLUSTER_OPACITY), + strokeWidth = const(2.dp), + strokeColor = const(Color.White), + ) + + // Cluster count labels + SymbolLayer( + id = "node-cluster-count", + source = nodesSource, + filter = feature.has("cluster"), + textField = feature["point_count"].asString(), + textColor = const(Color.White), + textSize = const(1.2f.em), + ) + + // Individual node markers + CircleLayer( + id = "node-markers", + source = nodesSource, + filter = !feature.has("cluster"), + radius = const(8.dp), + color = const(NodeMarkerColor), + strokeWidth = const(2.dp), + strokeColor = const(Color.White), + onClick = { features -> + val nodeNum = features.firstOrNull()?.properties?.get("node_num")?.toString()?.toIntOrNull() + if (nodeNum != null) { + onNodeClick(nodeNum) + ClickResult.Consume + } else { + ClickResult.Pass + } + }, + ) + + // Precision circles + if (showPrecisionCircle) { + CircleLayer( + id = "node-precision", + source = nodesSource, + filter = !feature.has("cluster"), + radius = const(40.dp), // TODO: scale by precision_meters and zoom + color = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), + strokeWidth = const(1.dp), + strokeColor = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)), + ) + } +} + +/** Waypoint markers rendered as symbol layer with emoji icons. */ +@Composable +private fun WaypointMarkerLayers(waypoints: Map) { + val featureCollection = remember(waypoints) { waypointsToFeatureCollection(waypoints) } + + val waypointSource = rememberGeoJsonSource(data = GeoJsonData.Features(featureCollection)) + + // Waypoint emoji labels + SymbolLayer( + id = "waypoint-markers", + source = waypointSource, + textField = feature["emoji"].asString(), + textSize = const(2f.em), + textAllowOverlap = const(true), + iconAllowOverlap = const(true), + ) + + // Waypoint name labels below emoji + SymbolLayer( + id = "waypoint-labels", + source = waypointSource, + textField = feature["name"].asString(), + textSize = const(1.em), + textOffset = offset(0f.em, 2f.em), + textColor = const(Color.DarkGray), + ) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt new file mode 100644 index 0000000000..94341d419a --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt @@ -0,0 +1,103 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.LineLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.GeoJsonOptions +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.util.ClickResult +import org.meshtastic.feature.map.util.positionsToLineString +import org.meshtastic.feature.map.util.positionsToPointFeatures + +private val TrackColor = Color(0xFF2196F3) +private val SelectedPointColor = Color(0xFFF44336) +private const val TRACK_OPACITY = 0.8f +private const val SELECTED_OPACITY = 0.9f + +/** + * Renders a position history track as a line with marker points. Replaces the Google Maps Polyline + MarkerComposable + * and OSMDroid Polyline overlay implementations. + */ +@Composable +fun NodeTrackLayers( + positions: List, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, +) { + if (positions.size < 2) return + + // Line track source + val lineFeatureCollection = remember(positions) { positionsToLineString(positions) } + + val lineSource = + rememberGeoJsonSource( + data = GeoJsonData.Features(lineFeatureCollection), + options = GeoJsonOptions(lineMetrics = true), + ) + + // Track line with gradient + LineLayer( + id = "node-track-line", + source = lineSource, + width = const(3.dp), + color = const(TrackColor), // Blue + opacity = const(TRACK_OPACITY), + ) + + // Position marker points + val pointFeatureCollection = remember(positions) { positionsToPointFeatures(positions) } + + val pointsSource = rememberGeoJsonSource(data = GeoJsonData.Features(pointFeatureCollection)) + + CircleLayer( + id = "node-track-points", + source = pointsSource, + radius = const(5.dp), + color = const(TrackColor), + strokeWidth = const(1.dp), + strokeColor = const(Color.White), + onClick = { features -> + val time = features.firstOrNull()?.properties?.get("time")?.toString()?.toIntOrNull() + if (time != null && onPositionSelected != null) { + onPositionSelected(time) + ClickResult.Consume + } else { + ClickResult.Pass + } + }, + ) + + // Highlight selected position + if (selectedPositionTime != null) { + CircleLayer( + id = "node-track-selected", + source = pointsSource, + radius = const(10.dp), + color = const(SelectedPointColor), // Red + strokeWidth = const(2.dp), + strokeColor = const(Color.White), + opacity = const(SELECTED_OPACITY), + ) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt new file mode 100644 index 0000000000..9c7e1220d0 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -0,0 +1,72 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.MaplibreMap +import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.proto.Position +import org.maplibre.spatialk.geojson.Position as GeoPosition + +private const val DEFAULT_TRACK_ZOOM = 13.0 +private const val COORDINATE_SCALE = 1e-7 + +/** + * Embeddable position-track map showing a polyline with markers for the given positions. + * + * Supports synchronized selection: [selectedPositionTime] highlights the corresponding marker and [onPositionSelected] + * is called when a marker is tapped, passing the `Position.time` for the host screen to synchronize its card list. + * + * Replaces both the Google Maps and OSMDroid flavor-specific NodeTrackMap implementations. + */ +@Composable +fun NodeTrackMap( + positions: List, + modifier: Modifier = Modifier, + selectedPositionTime: Int? = null, + onPositionSelected: ((Int) -> Unit)? = null, +) { + val center = + remember(positions) { + positions.firstOrNull()?.let { pos -> + val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE + val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE + if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null + } + } + + val cameraState = + rememberCameraState( + firstPosition = + CameraPosition( + target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0), + zoom = DEFAULT_TRACK_ZOOM, + ), + ) + + MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) { + NodeTrackLayers( + positions = positions, + selectedPositionTime = selectedPositionTime, + onPositionSelected = onPositionSelected, + ) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt new file mode 100644 index 0000000000..d87c216020 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -0,0 +1,193 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.maplibre.compose.expressions.dsl.asString +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.offset +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.LineLayer +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.spatialk.geojson.Feature +import org.maplibre.spatialk.geojson.FeatureCollection +import org.maplibre.spatialk.geojson.LineString +import org.maplibre.spatialk.geojson.Point +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.TracerouteOverlay +import org.maplibre.spatialk.geojson.Position as GeoPosition + +private val ForwardRouteColor = Color(0xFF4CAF50) +private val ReturnRouteColor = Color(0xFFF44336) +private val HopMarkerColor = Color(0xFF9C27B0) +private const val COORDINATE_SCALE = 1e-7 +private const val HEX_RADIX = 16 +private const val ROUTE_OPACITY = 0.8f + +/** + * Renders traceroute forward and return routes with hop markers. Replaces the Google Maps and OSMDroid traceroute + * polyline implementations. + */ +@Composable +fun TracerouteLayers( + overlay: TracerouteOverlay?, + nodePositions: Map, + nodes: Map, + onMappableCountChanged: (shown: Int, total: Int) -> Unit, +) { + if (overlay == null) return + + // Build route line features + val routeData = remember(overlay, nodePositions) { buildTracerouteGeoJson(overlay, nodePositions, nodes) } + + // Report mappable count + val mappableCount = routeData.hopFeatures.features.size + val totalCount = overlay.forwardRoute.size + overlay.returnRoute.size + onMappableCountChanged(mappableCount, totalCount) + + // Forward route line + if (routeData.forwardLine.features.isNotEmpty()) { + val forwardSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.forwardLine)) + LineLayer( + id = "traceroute-forward", + source = forwardSource, + width = const(3.dp), + color = const(ForwardRouteColor), // Green + opacity = const(ROUTE_OPACITY), + ) + } + + // Return route line (dashed) + if (routeData.returnLine.features.isNotEmpty()) { + val returnSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.returnLine)) + LineLayer( + id = "traceroute-return", + source = returnSource, + width = const(3.dp), + color = const(ReturnRouteColor), // Red + opacity = const(ROUTE_OPACITY), + dasharray = const(listOf(2f, 1f)), + ) + } + + // Hop markers + if (routeData.hopFeatures.features.isNotEmpty()) { + val hopsSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.hopFeatures)) + CircleLayer( + id = "traceroute-hops", + source = hopsSource, + radius = const(8.dp), + color = const(HopMarkerColor), // Purple + strokeWidth = const(2.dp), + strokeColor = const(Color.White), + ) + SymbolLayer( + id = "traceroute-hop-labels", + source = hopsSource, + textField = feature["short_name"].asString(), + textSize = const(1.em), + textOffset = offset(0f.em, -2f.em), + textColor = const(Color.DarkGray), + ) + } +} + +private data class TracerouteGeoJsonData( + val forwardLine: FeatureCollection, + val returnLine: FeatureCollection, + val hopFeatures: FeatureCollection, +) + +private fun buildTracerouteGeoJson( + overlay: TracerouteOverlay, + nodePositions: Map, + nodes: Map, +): TracerouteGeoJsonData { + fun nodeToGeoPosition(nodeNum: Int): GeoPosition? { + val pos = nodePositions[nodeNum] ?: return null + val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE + val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE + return if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat) + } + + // Build forward route line + val forwardCoords = overlay.forwardRoute.mapNotNull { nodeToGeoPosition(it) } + val forwardLine = + if (forwardCoords.size >= 2) { + val feature = + Feature( + geometry = LineString(forwardCoords), + properties = buildJsonObject { put("direction", "forward") }, + ) + @Suppress("UNCHECKED_CAST") + FeatureCollection(listOf(feature)) as FeatureCollection + } else { + @Suppress("UNCHECKED_CAST") + FeatureCollection(emptyList>()) as FeatureCollection + } + + // Build return route line + val returnCoords = overlay.returnRoute.mapNotNull { nodeToGeoPosition(it) } + val returnLine = + if (returnCoords.size >= 2) { + val feature = + Feature( + geometry = LineString(returnCoords), + properties = buildJsonObject { put("direction", "return") }, + ) + @Suppress("UNCHECKED_CAST") + FeatureCollection(listOf(feature)) as FeatureCollection + } else { + @Suppress("UNCHECKED_CAST") + FeatureCollection(emptyList>()) as FeatureCollection + } + + // Build hop marker points + val allNodeNums = overlay.relatedNodeNums + + val hopFeatures = + allNodeNums.mapNotNull { nodeNum -> + val geoPos = nodeToGeoPosition(nodeNum) ?: return@mapNotNull null + val node = nodes[nodeNum] + Feature( + geometry = Point(geoPos), + properties = + buildJsonObject { + put("node_num", nodeNum) + put("short_name", node?.user?.short_name ?: nodeNum.toUInt().toString(HEX_RADIX)) + put("long_name", node?.user?.long_name ?: "Unknown") + }, + ) + } + + @Suppress("UNCHECKED_CAST") + return TracerouteGeoJsonData( + forwardLine = forwardLine, + returnLine = returnLine, + hopFeatures = FeatureCollection(hopFeatures) as FeatureCollection, + ) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt new file mode 100644 index 0000000000..e198397c4d --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -0,0 +1,75 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.MaplibreMap +import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.proto.Position +import org.maplibre.spatialk.geojson.Position as GeoPosition + +private const val DEFAULT_TRACEROUTE_ZOOM = 10.0 +private const val COORDINATE_SCALE = 1e-7 + +/** + * Embeddable traceroute map showing forward/return route polylines with hop markers. + * + * This composable is designed to be embedded inside a parent scaffold (e.g. TracerouteMapScreen). It does NOT include + * its own Scaffold or AppBar. + * + * Replaces both the Google Maps and OSMDroid flavor-specific TracerouteMap implementations. + */ +@Composable +fun TracerouteMap( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + onMappableCountChanged: (shown: Int, total: Int) -> Unit, + modifier: Modifier = Modifier, +) { + // Center the camera on the first node with a known position. + val center = + remember(tracerouteNodePositions) { + tracerouteNodePositions.values.firstOrNull()?.let { pos -> + val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE + val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE + if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null + } + } + + val cameraState = + rememberCameraState( + firstPosition = + CameraPosition( + target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0), + zoom = DEFAULT_TRACEROUTE_ZOOM, + ), + ) + + MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) { + TracerouteLayers( + overlay = tracerouteOverlay, + nodePositions = tracerouteNodePositions, + nodes = emptyMap(), // Node lookups for short names are best-effort + onMappableCountChanged = onMappableCountChanged, + ) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt new file mode 100644 index 0000000000..21f063433c --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt @@ -0,0 +1,52 @@ +/* + * 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.model + +import org.jetbrains.compose.resources.StringResource +import org.maplibre.compose.style.BaseStyle +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.map_style_dark +import org.meshtastic.core.resources.map_style_hybrid +import org.meshtastic.core.resources.map_style_osm +import org.meshtastic.core.resources.map_style_satellite +import org.meshtastic.core.resources.map_style_terrain + +/** + * Predefined map tile styles available in the app. + * + * Uses free tile sources that do not require API keys. Custom XYZ tile URLs and offline sources can be configured + * separately via [MapLayerItem]. + */ +enum class MapStyle(val label: StringResource, val styleUri: String) { + /** OpenStreetMap default tiles via OpenFreeMap Liberty style. */ + OpenStreetMap(label = Res.string.map_style_osm, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + + /** Satellite imagery — uses OpenFreeMap with a raster overlay switcher. */ + Satellite(label = Res.string.map_style_satellite, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + + /** Terrain style. */ + Terrain(label = Res.string.map_style_terrain, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + + /** Satellite + labels hybrid. */ + Hybrid(label = Res.string.map_style_hybrid, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + + /** Dark mode style. */ + Dark(label = Res.string.map_style_dark, styleUri = "https://tiles.openfreemap.org/styles/bright"), + ; + + fun toBaseStyle(): BaseStyle = BaseStyle.Uri(styleUri) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt index 00df4cac3b..ab526e973d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -19,16 +19,20 @@ package org.meshtastic.feature.map.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.MapRoute import org.meshtastic.core.navigation.NodesRoute +import org.meshtastic.feature.map.MapScreen +import org.meshtastic.feature.map.MapViewModel fun EntryProviderScope.mapGraph(backStack: NavBackStack) { entry { args -> - val mapScreen = org.meshtastic.core.ui.util.LocalMapMainScreenProvider.current - mapScreen( - { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // onClickNodeChip - { id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails - args.waypointId, + val viewModel = koinViewModel() + MapScreen( + viewModel = viewModel, + onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) }, + navigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) }, + waypointId = args.waypointId, ) } } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt similarity index 60% rename from androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt index 19ce8cff75..6bae9a23c3 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt @@ -14,7 +14,7 @@ * 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 +package org.meshtastic.feature.map.node import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -23,20 +23,28 @@ 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.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.map import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.feature.map.node.NodeMapViewModel +import org.meshtastic.feature.map.component.NodeTrackMap +/** + * Full-screen map showing a single node's position history. + * + * Includes a Scaffold with AppBar showing the node's long name. Replaces both the Google Maps and OSMDroid + * flavor-specific NodeMapScreen implementations. + */ @Composable -fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val node by nodeMapViewModel.node.collectAsStateWithLifecycle() - val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() +fun NodeMapScreen(viewModel: NodeMapViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) { + val node by viewModel.node.collectAsStateWithLifecycle() + val positions by viewModel.positionLogs.collectAsStateWithLifecycle() Scaffold( + modifier = modifier, topBar = { MainAppBar( - title = node?.user?.long_name ?: "", + title = node?.user?.long_name ?: stringResource(Res.string.map), ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -46,9 +54,6 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) ) }, ) { paddingValues -> - MapView( - modifier = Modifier.fillMaxSize().padding(paddingValues), - mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions), - ) + NodeTrackMap(positions = positions, modifier = Modifier.fillMaxSize().padding(paddingValues)) } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt new file mode 100644 index 0000000000..a28c766271 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -0,0 +1,178 @@ +/* + * 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.util + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.maplibre.spatialk.geojson.Feature +import org.maplibre.spatialk.geojson.FeatureCollection +import org.maplibre.spatialk.geojson.LineString +import org.maplibre.spatialk.geojson.Point +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.maplibre.spatialk.geojson.Position as GeoPosition + +/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */ +private const val COORDINATE_SCALE = 1e-7 + +private const val MIN_PRECISION_BITS = 10 +private const val MAX_PRECISION_BITS = 19 + +/** Convert a list of nodes to a GeoJSON [FeatureCollection] for map rendering. */ +fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): FeatureCollection { + val features = + nodes.mapNotNull { node -> + val pos = node.validPosition ?: return@mapNotNull null + val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE + val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE + if (lat == 0.0 && lng == 0.0) return@mapNotNull null + + val colors = node.colors + val props = buildJsonObject { + put("node_num", node.num) + put("short_name", node.user.short_name) + put("long_name", node.user.long_name) + put("last_heard", node.lastHeard) + put("is_favorite", node.isFavorite) + put("is_my_node", node.num == myNodeNum) + put("hops_away", node.hopsAway) + put("via_mqtt", node.viaMqtt) + put("snr", node.snr.toDouble()) + put("rssi", node.rssi) + put("foreground_color", colors.first) + put("background_color", colors.second) + put("has_precision", (pos.precision_bits ?: 0) in MIN_PRECISION_BITS..MAX_PRECISION_BITS) + put("precision_meters", precisionBitsToMeters(pos.precision_bits ?: 0)) + } + + Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + } + + @Suppress("UNCHECKED_CAST") + return FeatureCollection(features) as FeatureCollection +} + +/** Convert waypoints to a GeoJSON [FeatureCollection]. */ +fun waypointsToFeatureCollection(waypoints: Map): FeatureCollection { + val features = + waypoints.values.mapNotNull { packet -> + val waypoint = packet.waypoint ?: return@mapNotNull null + val lat = (waypoint.latitude_i ?: 0) * COORDINATE_SCALE + val lng = (waypoint.longitude_i ?: 0) * COORDINATE_SCALE + if (lat == 0.0 && lng == 0.0) return@mapNotNull null + + val emoji = if (waypoint.icon != 0) convertIntToEmoji(waypoint.icon) else PIN_EMOJI + + val props = buildJsonObject { + put("waypoint_id", waypoint.id) + put("name", waypoint.name) + put("description", waypoint.description) + put("emoji", emoji) + put("icon", waypoint.icon) + put("locked_to", waypoint.locked_to) + put("expire", waypoint.expire) + } + + Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + } + + @Suppress("UNCHECKED_CAST") + return FeatureCollection(features) as FeatureCollection +} + +/** Convert position history to a GeoJSON [LineString] for track rendering. */ +fun positionsToLineString(positions: List): FeatureCollection { + val coords = + positions.mapNotNull { pos -> + val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE + val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE + if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat) + } + + if (coords.size < 2) return FeatureCollection(emptyList()) + + val props = buildJsonObject { put("point_count", coords.size) } + + val feature = Feature(geometry = LineString(coords), properties = props) + + @Suppress("UNCHECKED_CAST") + return FeatureCollection(listOf(feature)) as FeatureCollection +} + +/** Convert position history to individual point features with time metadata. */ +fun positionsToPointFeatures(positions: List): FeatureCollection { + val features = + positions.mapNotNull { pos -> + val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE + val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE + if (lat == 0.0 && lng == 0.0) return@mapNotNull null + + val props = buildJsonObject { + put("time", pos.time ?: 0) + put("altitude", pos.altitude ?: 0) + put("ground_speed", pos.ground_speed ?: 0) + put("sats_in_view", pos.sats_in_view ?: 0) + } + + Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + } + + @Suppress("UNCHECKED_CAST") + return FeatureCollection(features) as FeatureCollection +} + +/** Approximate meters of positional uncertainty from precision_bits (10-19). */ +@Suppress("MagicNumber") +fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) { + 10 -> 5886.0 + 11 -> 2944.0 + 12 -> 1472.0 + 13 -> 736.0 + 14 -> 368.0 + 15 -> 184.0 + 16 -> 92.0 + 17 -> 46.0 + 18 -> 23.0 + 19 -> 11.5 + else -> 0.0 +} + +private const val PIN_EMOJI = "\uD83D\uDCCD" +private const val BMP_MAX = 0xFFFF +private const val SUPPLEMENTARY_OFFSET = 0x10000 +private const val HALF_SHIFT = 10 +private const val HIGH_SURROGATE_BASE = 0xD800 +private const val LOW_SURROGATE_BASE = 0xDC00 +private const val SURROGATE_MASK = 0x3FF + +/** Convert a Unicode code point integer to its emoji string representation. */ +internal fun convertIntToEmoji(codePoint: Int): String = try { + if (codePoint <= BMP_MAX) { + codePoint.toChar().toString() + } else { + val offset = codePoint - SUPPLEMENTARY_OFFSET + val high = (offset shr HALF_SHIFT) + HIGH_SURROGATE_BASE + val low = (offset and SURROGATE_MASK) + LOW_SURROGATE_BASE + buildString { + append(high.toChar()) + append(low.toChar()) + } + } +} catch (_: Exception) { + PIN_EMOJI +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt new file mode 100644 index 0000000000..7981ab1df3 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt @@ -0,0 +1,184 @@ +/* + * 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 androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +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.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.maplibre.compose.style.BaseStyle +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.testing.FakeMapCameraPrefs +import org.meshtastic.core.testing.FakeMapPrefs +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeRadioController +import org.meshtastic.feature.map.model.MapStyle +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class MapViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var viewModel: MapViewModel + private lateinit var mapCameraPrefs: FakeMapCameraPrefs + private lateinit var mapPrefs: FakeMapPrefs + private val packetRepository: PacketRepository = mock() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + mapCameraPrefs = FakeMapCameraPrefs() + mapPrefs = FakeMapPrefs() + every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList()) + viewModel = createViewModel() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()): MapViewModel = MapViewModel( + mapPrefs = mapPrefs, + mapCameraPrefs = mapCameraPrefs, + nodeRepository = FakeNodeRepository(), + packetRepository = packetRepository, + radioController = FakeRadioController(), + savedStateHandle = savedStateHandle, + ) + + @Test + fun selectedWaypointIdDefaultsToNull() { + assertNull(viewModel.selectedWaypointId.value) + } + + @Test + fun selectedWaypointIdRestoredFromSavedState() { + val vm = createViewModel(SavedStateHandle(mapOf("waypointId" to 42))) + assertEquals(42, vm.selectedWaypointId.value) + } + + @Test + fun setWaypointIdUpdatesState() { + viewModel.setWaypointId(7) + assertEquals(7, viewModel.selectedWaypointId.value) + + viewModel.setWaypointId(null) + assertNull(viewModel.selectedWaypointId.value) + } + + @Test + fun initialCameraPositionReflectsPrefs() { + mapCameraPrefs.setCameraLat(51.5) + mapCameraPrefs.setCameraLng(-0.1) + mapCameraPrefs.setCameraZoom(12f) + mapCameraPrefs.setCameraTilt(30f) + mapCameraPrefs.setCameraBearing(45f) + + val vm = createViewModel() + val pos = vm.initialCameraPosition + + assertEquals(51.5, pos.target.latitude) + assertEquals(-0.1, pos.target.longitude) + assertEquals(12.0, pos.zoom) + assertEquals(30.0, pos.tilt) + assertEquals(45.0, pos.bearing) + } + + @Test + fun saveCameraPositionPersistsToPrefs() { + val cameraPosition = + org.maplibre.compose.camera.CameraPosition( + target = org.maplibre.spatialk.geojson.Position(longitude = -122.4, latitude = 37.8), + zoom = 15.0, + tilt = 20.0, + bearing = 90.0, + ) + + viewModel.saveCameraPosition(cameraPosition) + + assertEquals(37.8, mapCameraPrefs.cameraLat.value) + assertEquals(-122.4, mapCameraPrefs.cameraLng.value) + assertEquals(15f, mapCameraPrefs.cameraZoom.value) + assertEquals(20f, mapCameraPrefs.cameraTilt.value) + assertEquals(90f, mapCameraPrefs.cameraBearing.value) + } + + @Test + fun baseStyleDefaultsToOpenStreetMap() = runTest(testDispatcher) { + viewModel.baseStyle.test { + val style = awaitItem() + assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun selectMapStyleUpdatesBaseStyleAndSelectedMapStyle() = runTest(testDispatcher) { + viewModel.selectedMapStyle.test { + assertEquals(MapStyle.OpenStreetMap, awaitItem()) + + viewModel.selectMapStyle(MapStyle.Dark) + assertEquals(MapStyle.Dark, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun baseStyleEmitsUriOnStyleChange() = runTest(testDispatcher) { + viewModel.baseStyle.test { + // Initial style + awaitItem() + + viewModel.selectMapStyle(MapStyle.Dark) + val darkStyle = awaitItem() + assertEquals(BaseStyle.Uri(MapStyle.Dark.styleUri), darkStyle) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun compassBearingReflectsPrefs() { + mapCameraPrefs.setCameraBearing(180f) + val vm = createViewModel() + assertEquals(180f, vm.compassBearing) + } + + @Test + fun blankStyleUriFallsBackToOpenStreetMap() = runTest(testDispatcher) { + // selectedStyleUri defaults to "" in FakeMapCameraPrefs + viewModel.baseStyle.test { + val style = awaitItem() + assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style) + cancelAndIgnoreRemainingEvents() + } + } +} 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 13db96d5ad..9ebfb846cf 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 @@ -43,7 +43,7 @@ import org.meshtastic.core.resources.open_compass import org.meshtastic.core.ui.icon.Compass import org.meshtastic.core.ui.icon.Distance import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.util.LocalInlineMapProvider +import org.meshtastic.feature.map.component.InlineMap import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.proto.Config @@ -85,7 +85,7 @@ internal fun PositionInlineContent( private fun PositionMap(node: Node, distance: String?) { Box(modifier = Modifier.padding(vertical = 4.dp)) { Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(MAP_HEIGHT_DP.dp)) { - LocalInlineMapProvider.current(node, Modifier.fillMaxSize()) + InlineMap(node, Modifier.fillMaxSize()) } if (distance != null && distance.isNotEmpty()) { Surface( 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 a52f046c5b..14061cc769 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 @@ -31,8 +31,8 @@ import org.meshtastic.core.resources.position_log import org.meshtastic.core.ui.icon.Delete import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh -import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider import org.meshtastic.core.ui.util.rememberSaveFileLauncher +import org.meshtastic.feature.map.component.NodeTrackMap @Composable fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { @@ -41,9 +41,6 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { val exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) } - val trackMap = LocalNodeTrackMapProvider.current - val destNum = state.node?.num ?: 0 - BaseMetricScreen( onNavigateUp = onNavigateUp, telemetryType = null, @@ -66,7 +63,12 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { }, chartPart = { modifier, selectedX, _, onPointSelected -> val selectedTime = selectedX?.toInt() - trackMap(destNum, positions, modifier, selectedTime) { time -> onPointSelected(time.toDouble()) } + NodeTrackMap( + positions = positions, + modifier = modifier, + selectedPositionTime = selectedTime, + onPositionSelected = { time -> onPointSelected(time.toDouble()) }, + ) }, listPart = { modifier, selectedX, lazyListState, onCardClick -> LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) { 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 91% 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 index 470bcf6930..83079acb39 100644 --- 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 @@ -52,8 +52,7 @@ 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.LocalTracerouteMapOverlayInsetsProvider -import org.meshtastic.core.ui.util.LocalTracerouteMapProvider +import org.meshtastic.feature.map.component.TracerouteMap import org.meshtastic.proto.Position @Composable @@ -102,7 +101,6 @@ private fun TracerouteMapScaffold( ) { var tracerouteNodesShown by remember { mutableStateOf(0) } var tracerouteNodesTotal by remember { mutableStateOf(0) } - val insets = LocalTracerouteMapOverlayInsetsProvider.current Scaffold( topBar = { MainAppBar( @@ -117,18 +115,18 @@ private fun TracerouteMapScaffold( }, ) { paddingValues -> Box(modifier = modifier.fillMaxSize().padding(paddingValues)) { - LocalTracerouteMapProvider.current( - overlay, - snapshotPositions, - { shown: Int, total: Int -> + TracerouteMap( + tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, + onMappableCountChanged = { shown: Int, total: Int -> tracerouteNodesShown = shown tracerouteNodesTotal = total }, - Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), ) Column( - modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding), - horizontalAlignment = insets.contentHorizontalAlignment, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal) 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 ee4955c321..43558a0475 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 @@ -66,6 +66,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) @@ -125,12 +126,13 @@ 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 = { backStack.removeLastOrNull() }, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 16324a9850..55c4eb56ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,8 +53,8 @@ androidx-compose-bom-aligned = "1.11.2" androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-beta01" -# Google -maps-compose = "8.3.0" +# MapLibre +maplibre-compose = "0.12.1" # ML Kit mlkit-barcode-scanning = "17.3.0" @@ -80,7 +80,6 @@ google-services-gradle = "4.4.4" markdownRenderer = "0.41.0" okio = "3.17.0" uri-kmp = "0.0.21" -osmdroid-android = "6.1.20" spotless = "8.6.0" wire = "6.4.0" vico = "3.2.1" @@ -173,12 +172,10 @@ koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", ver koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" } -maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" } -maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" } -maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" } +maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre-compose" } +maplibre-compose-material3 = { module = "org.maplibre.compose:maplibre-compose-material3", version.ref = "maplibre-compose" } mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" } mlkit-translate = { module = "com.google.mlkit:translate", version.ref = "mlkit-translate" } -play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" } wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" } zxing-core = { module = "com.google.zxing:core", version = "3.5.4" } qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode-kotlin" } @@ -247,8 +244,6 @@ jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" okio = { module = "com.squareup.okio:okio", version.ref = "okio" } uri-kmp = { module = "com.eygraber:uri-kmp", version.ref = "uri-kmp" } osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" } -osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" } -osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" } kermit = { module = "co.touchlab:kermit", version = "2.1.0" } usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" } From 6b736c401e9d308c4a3af4b4b5844b6277eb34e0 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 12 Apr 2026 19:25:04 -0500 Subject: [PATCH 02/23] =?UTF-8?q?feat(map):=20add=20feature=20parity=20?= =?UTF-8?q?=E2=80=94=20filters,=20style=20selector,=20waypoint=20dialog,?= =?UTF-8?q?=20cluster=20zoom,=20bounds=20fitting,=20location=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire remaining map feature gaps identified in the parity audit: - MapFilterDropdown: favorites, waypoints, precision circle toggles and last-heard slider matching the old Google/OSMDroid filter UIs - MapStyleSelector: dropdown with 5 predefined MapStyle entries - EditWaypointDialog: create, edit, delete waypoints via long-press or marker tap, with icon picker and lock toggle - Cluster zoom-to-expand: tap a cluster circle to zoom +2 levels centered on the cluster position - Bounds fitting: NodeTrackMap and TracerouteMap compute a BoundingBox from all positions and animate the camera to fit on first load - Location tracking: expect/actual rememberLocationProviderOrNull() bridges platform GPS into maplibre-compose LocationPuck with LocationTrackingEffect for auto-pan and bearing follow - Per-node marker colors via data-driven convertToColor() expressions - Waypoint camera animation on deep-link selection - Compass click resets bearing to north --- .../feature/map/LocationProviderFactory.kt | 23 +++ .../feature/map/LocationProviderFactory.kt | 28 +++ .../org/meshtastic/feature/map/MapScreen.kt | 151 +++++++++++++++- .../map/component/EditWaypointDialog.kt | 168 ++++++++++++++++++ .../map/component/MapFilterDropdown.kt | 129 ++++++++++++++ .../feature/map/component/MapStyleSelector.kt | 76 ++++++++ .../map/component/MaplibreMapContent.kt | 86 +++++++-- .../feature/map/component/NodeTrackMap.kt | 27 ++- .../feature/map/component/TracerouteMap.kt | 28 ++- .../feature/map/util/GeoJsonConverters.kt | 12 +- .../feature/map/LocationProviderFactory.kt | 23 +++ .../feature/map/LocationProviderFactory.kt | 23 +++ 12 files changed, 750 insertions(+), 24 deletions(-) create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt create mode 100644 feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt create mode 100644 feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt new file mode 100644 index 0000000000..d98dc681a1 --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt @@ -0,0 +1,23 @@ +/* + * 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 androidx.compose.runtime.Composable +import org.maplibre.compose.location.LocationProvider +import org.maplibre.compose.location.rememberDefaultLocationProvider + +@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = rememberDefaultLocationProvider() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt new file mode 100644 index 0000000000..7bda5766d2 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt @@ -0,0 +1,28 @@ +/* + * 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 androidx.compose.runtime.Composable +import org.maplibre.compose.location.LocationProvider + +/** + * Returns a platform-appropriate [LocationProvider], or `null` if the platform doesn't support location. + * - Android: uses the platform `LocationManager` via `rememberDefaultLocationProvider()`. + * - iOS: uses `CLLocationManager` via `rememberDefaultLocationProvider()`. + * - Desktop/JS: returns `null` (no location hardware). + */ +@Composable expect fun rememberLocationProviderOrNull(): LocationProvider? diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 92291d3f3f..d610935e0d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -23,16 +23,35 @@ import androidx.compose.material3.Scaffold 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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource +import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.location.LocationTrackingEffect +import org.maplibre.compose.location.rememberNullLocationProvider +import org.maplibre.compose.location.rememberUserLocationState +import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.feature.map.component.EditWaypointDialog import org.meshtastic.feature.map.component.MapControlsOverlay +import org.meshtastic.feature.map.component.MapFilterDropdown +import org.meshtastic.feature.map.component.MapStyleSelector import org.meshtastic.feature.map.component.MaplibreMapContent +import org.meshtastic.proto.Waypoint +import org.maplibre.spatialk.geojson.Position as GeoPosition + +/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */ +private const val COORDINATE_SCALE = 1e-7 +private const val WAYPOINT_ZOOM = 15.0 /** * Main map screen composable. Uses MapLibre Compose Multiplatform to render an interactive map with mesh node markers, @@ -41,6 +60,7 @@ import org.meshtastic.feature.map.component.MaplibreMapContent * This replaces the previous flavor-specific Google Maps and OSMDroid implementations with a single cross-platform * composable. */ +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun MapScreen( onClickNodeChip: (Int) -> Unit, @@ -55,11 +75,55 @@ fun MapScreen( val waypoints by viewModel.waypoints.collectAsStateWithLifecycle() val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle() val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle() + val selectedMapStyle by viewModel.selectedMapStyle.collectAsStateWithLifecycle() LaunchedEffect(waypointId) { viewModel.setWaypointId(waypointId) } val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition) + var filterMenuExpanded by remember { mutableStateOf(false) } + + // Waypoint dialog state + var showWaypointDialog by remember { mutableStateOf(false) } + var longPressPosition by remember { mutableStateOf(null) } + var editingWaypointId by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + + // Location tracking state + var isLocationTrackingEnabled by remember { mutableStateOf(false) } + val locationProvider = rememberLocationProviderOrNull() + val locationState = rememberUserLocationState(locationProvider ?: rememberNullLocationProvider()) + val locationAvailable = locationProvider != null + + // Animate to waypoint when waypointId is provided (deep-link) + val selectedWaypointId by viewModel.selectedWaypointId.collectAsStateWithLifecycle() + LaunchedEffect(selectedWaypointId, waypoints) { + val wpId = selectedWaypointId ?: return@LaunchedEffect + val packet = waypoints[wpId] ?: return@LaunchedEffect + val wpt = packet.waypoint ?: return@LaunchedEffect + val lat = (wpt.latitude_i ?: 0) * COORDINATE_SCALE + val lng = (wpt.longitude_i ?: 0) * COORDINATE_SCALE + if (lat != 0.0 || lng != 0.0) { + cameraState.animateTo( + CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = WAYPOINT_ZOOM), + ) + } + } + + // Apply favorites and last-heard filters to the node list + val myNum = viewModel.myNodeNum + val filteredNodes = + remember(nodesWithPosition, filterState, myNum) { + nodesWithPosition + .filter { node -> !filterState.onlyFavorites || node.isFavorite || node.num == myNum } + .filter { node -> + filterState.lastHeardFilter.seconds == 0L || + (nowSeconds - node.lastHeard) <= filterState.lastHeardFilter.seconds || + node.num == myNum + } + } + @Suppress("ViewModelForwarding") Scaffold( modifier = modifier, @@ -77,7 +141,7 @@ fun MapScreen( ) { paddingValues -> Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { MaplibreMapContent( - nodes = nodesWithPosition, + nodes = filteredNodes, waypoints = waypoints, baseStyle = baseStyle, cameraState = cameraState, @@ -86,20 +150,95 @@ fun MapScreen( showPrecisionCircle = filterState.showPrecisionCircle, onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) }, onMapLongClick = { position -> - // TODO: open waypoint creation dialog at position + longPressPosition = position + editingWaypointId = null + showWaypointDialog = true }, modifier = Modifier.fillMaxSize(), onCameraMoved = { position -> viewModel.saveCameraPosition(position) }, + onWaypointClick = { wpId -> + editingWaypointId = wpId + longPressPosition = null + showWaypointDialog = true + }, + locationState = if (isLocationTrackingEnabled && locationAvailable) locationState else null, ) + // Auto-pan camera when location tracking is enabled + if (locationAvailable) { + LocationTrackingEffect(locationState = locationState, enabled = isLocationTrackingEnabled) { + cameraState.updateFromLocation() + } + } + MapControlsOverlay( - onToggleFilterMenu = {}, + onToggleFilterMenu = { filterMenuExpanded = !filterMenuExpanded }, modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues), bearing = cameraState.position.bearing.toFloat(), - onCompassClick = {}, - isLocationTrackingEnabled = false, - onToggleLocationTracking = {}, + onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } }, + filterDropdownContent = { + MapFilterDropdown( + expanded = filterMenuExpanded, + onDismissRequest = { filterMenuExpanded = false }, + filterState = filterState, + onToggleFavorites = viewModel::toggleOnlyFavorites, + onToggleWaypoints = viewModel::toggleShowWaypointsOnMap, + onTogglePrecisionCircle = viewModel::toggleShowPrecisionCircleOnMap, + onSetLastHeardFilter = viewModel::setLastHeardFilter, + ) + }, + mapTypeContent = { + MapStyleSelector(selectedStyle = selectedMapStyle, onSelectStyle = viewModel::selectMapStyle) + }, + isLocationTrackingEnabled = isLocationTrackingEnabled, + onToggleLocationTracking = { isLocationTrackingEnabled = !isLocationTrackingEnabled }, ) } } + + // Waypoint creation/edit dialog + if (showWaypointDialog) { + val editingPacket = editingWaypointId?.let { waypoints[it] } + val editingWaypoint = editingPacket?.waypoint + + EditWaypointDialog( + onDismiss = { + showWaypointDialog = false + editingWaypointId = null + longPressPosition = null + }, + onSend = { name, description, icon, locked, expire -> + val myNodeNum = viewModel.myNodeNum ?: 0 + val wpt = + Waypoint( + id = editingWaypoint?.id ?: viewModel.generatePacketId(), + name = name, + description = description, + icon = icon, + locked_to = if (locked) myNodeNum else 0, + latitude_i = + if (editingWaypoint != null) { + editingWaypoint.latitude_i + } else { + longPressPosition?.let { (it.latitude / COORDINATE_SCALE).toInt() } ?: 0 + }, + longitude_i = + if (editingWaypoint != null) { + editingWaypoint.longitude_i + } else { + longPressPosition?.let { (it.longitude / COORDINATE_SCALE).toInt() } ?: 0 + }, + expire = expire, + ) + viewModel.sendWaypoint(wpt) + }, + onDelete = editingWaypoint?.let { wpt -> { viewModel.deleteWaypoint(wpt.id) } }, + initialName = editingWaypoint?.name ?: "", + initialDescription = editingWaypoint?.description ?: "", + initialIcon = editingWaypoint?.icon ?: 0, + initialLocked = (editingWaypoint?.locked_to ?: 0) != 0, + isEditing = editingWaypoint != null, + position = longPressPosition, + ) + } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt new file mode 100644 index 0000000000..1392ab1aaa --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -0,0 +1,168 @@ +/* + * 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.feature.map.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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.delete +import org.meshtastic.core.resources.description +import org.meshtastic.core.resources.name +import org.meshtastic.core.resources.send +import org.meshtastic.feature.map.util.convertIntToEmoji +import org.maplibre.spatialk.geojson.Position as GeoPosition + +private const val MAX_NAME_LENGTH = 29 +private const val MAX_DESCRIPTION_LENGTH = 99 +private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin + +/** + * Dialog for creating or editing a waypoint on the map. + * + * Replaces the old Android-specific `EditWaypointDialog` with a fully cross-platform Compose Multiplatform version. + * Date/time picker for expiry is deferred (requires platform-specific pickers or CMP M3 DatePicker availability). + */ +@Suppress("LongParameterList", "LongMethod") +@Composable +fun EditWaypointDialog( + onDismiss: () -> Unit, + onSend: (name: String, description: String, icon: Int, locked: Boolean, expire: Int) -> Unit, + onDelete: (() -> Unit)? = null, + initialName: String = "", + initialDescription: String = "", + initialIcon: Int = DEFAULT_EMOJI, + initialLocked: Boolean = false, + isEditing: Boolean = false, + position: GeoPosition? = null, +) { + var name by remember { mutableStateOf(initialName) } + var description by remember { mutableStateOf(initialDescription) } + var emojiCodepoint by remember { mutableIntStateOf(if (initialIcon != 0) initialIcon else DEFAULT_EMOJI) } + var locked by remember { mutableStateOf(initialLocked) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = if (isEditing) "Edit Waypoint" else "New Waypoint", + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + // Emoji + Name row + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = convertIntToEmoji(emojiCodepoint), + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(end = 8.dp), + ) + OutlinedTextField( + value = name, + onValueChange = { if (it.length <= MAX_NAME_LENGTH) name = it }, + label = { Text(stringResource(Res.string.name)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Description + OutlinedTextField( + value = description, + onValueChange = { if (it.length <= MAX_DESCRIPTION_LENGTH) description = it }, + label = { Text(stringResource(Res.string.description)) }, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Lock toggle + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Lock to my node", style = MaterialTheme.typography.bodyMedium) + Switch(checked = locked, onCheckedChange = { locked = it }) + } + + // Position info + if (position != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${position.latitude}, ${position.longitude}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + dismissButton = { + Row { + if (onDelete != null) { + TextButton( + onClick = { + onDelete() + onDismiss() + }, + ) { + Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error) + } + Spacer(modifier = Modifier.width(8.dp)) + } + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } + } + }, + confirmButton = { + TextButton( + onClick = { + onSend(name, description, emojiCodepoint, locked, 0) + onDismiss() + }, + enabled = name.isNotBlank(), + ) { + Text(stringResource(Res.string.send)) + } + }, + ) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt new file mode 100644 index 0000000000..53f3d5dc2f --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt @@ -0,0 +1,129 @@ +/* + * 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.feature.map.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.last_heard_filter_label +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.PinDrop +import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState +import org.meshtastic.feature.map.LastHeardFilter +import kotlin.math.roundToInt + +/** + * Dropdown menu for filtering map markers by favorites, waypoints, precision circles, and last-heard time. + * + * Mirrors the old Google/F-Droid `MapFilterDropdown` — checkboxes for boolean toggles and a slider for last-heard time + * filter. + */ +@Composable +internal fun MapFilterDropdown( + expanded: Boolean, + onDismissRequest: () -> Unit, + filterState: MapFilterState, + onToggleFavorites: () -> Unit, + onToggleWaypoints: () -> Unit, + onTogglePrecisionCircle: () -> Unit, + onSetLastHeardFilter: (LastHeardFilter) -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.only_favorites)) }, + onClick = onToggleFavorites, + leadingIcon = { + Icon( + imageVector = MeshtasticIcons.Favorite, + contentDescription = stringResource(Res.string.only_favorites), + ) + }, + trailingIcon = { Checkbox(checked = filterState.onlyFavorites, onCheckedChange = { onToggleFavorites() }) }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.show_waypoints)) }, + onClick = onToggleWaypoints, + leadingIcon = { + Icon( + imageVector = MeshtasticIcons.PinDrop, + contentDescription = stringResource(Res.string.show_waypoints), + ) + }, + trailingIcon = { Checkbox(checked = filterState.showWaypoints, onCheckedChange = { onToggleWaypoints() }) }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.show_precision_circle)) }, + onClick = onTogglePrecisionCircle, + leadingIcon = { + Icon( + imageVector = MeshtasticIcons.Lens, + contentDescription = stringResource(Res.string.show_precision_circle), + ) + }, + trailingIcon = { + Checkbox(checked = filterState.showPrecisionCircle, onCheckedChange = { onTogglePrecisionCircle() }) + }, + ) + HorizontalDivider() + LastHeardSlider(filterState.lastHeardFilter, onSetLastHeardFilter) + } +} + +@Composable +private fun LastHeardSlider(currentFilter: LastHeardFilter, onSetFilter: (LastHeardFilter) -> Unit) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + val filterOptions = LastHeardFilter.entries + val selectedIndex = filterOptions.indexOf(currentFilter) + var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } + + Text( + text = stringResource(Res.string.last_heard_filter_label, stringResource(currentFilter.label)), + style = MaterialTheme.typography.labelLarge, + ) + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { + val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1) + onSetFilter(filterOptions[newIndex]) + }, + valueRange = 0f..(filterOptions.size - 1).toFloat(), + steps = filterOptions.size - 2, + ) + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt new file mode 100644 index 0000000000..a50c9e2a7b --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt @@ -0,0 +1,76 @@ +/* + * 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.feature.map.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +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 org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.map_style_selection +import org.meshtastic.core.resources.selected_map_type +import org.meshtastic.core.ui.icon.Check +import org.meshtastic.core.ui.icon.Layers +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.feature.map.model.MapStyle + +/** + * Map style selector button + dropdown menu. Shows predefined [MapStyle] entries with a checkmark next to the currently + * selected style. + */ +@Composable +internal fun MapStyleSelector(selectedStyle: MapStyle, onSelectStyle: (MapStyle) -> Unit) { + var expanded by remember { mutableStateOf(false) } + + Box { + MapButton( + icon = MeshtasticIcons.Layers, + contentDescription = stringResource(Res.string.map_style_selection), + onClick = { expanded = true }, + ) + + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + MapStyle.entries.forEach { style -> + DropdownMenuItem( + text = { Text(stringResource(style.label)) }, + onClick = { + onSelectStyle(style) + expanded = false + }, + trailingIcon = + if (selectedStyle == style) { + { + Icon( + MeshtasticIcons.Check, + contentDescription = stringResource(Res.string.selected_map_type), + ) + } + } else { + null + }, + ) + } + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 19e65518b5..f749d3a893 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -19,25 +19,33 @@ package org.meshtastic.feature.map.component import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em +import kotlinx.coroutines.launch import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraState import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.convertToColor +import org.maplibre.compose.expressions.dsl.convertToNumber +import org.maplibre.compose.expressions.dsl.dp import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.dsl.offset import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.location.LocationPuck +import org.maplibre.compose.location.UserLocationState import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonOptions import org.maplibre.compose.sources.rememberGeoJsonSource import org.maplibre.compose.style.BaseStyle import org.maplibre.compose.util.ClickResult +import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.feature.map.util.nodesToFeatureCollection @@ -50,6 +58,8 @@ private const val CLUSTER_MIN_POINTS = 10 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f private const val CLUSTER_OPACITY = 0.85f +private const val LABEL_OFFSET_EM = 1.5f +private const val CLUSTER_ZOOM_INCREMENT = 2.0 /** * Main map content composable using MapLibre Compose Multiplatform. @@ -70,6 +80,8 @@ fun MaplibreMapContent( onMapLongClick: (GeoPosition) -> Unit, modifier: Modifier = Modifier, onCameraMoved: (CameraPosition) -> Unit = {}, + onWaypointClick: (Int) -> Unit = {}, + locationState: UserLocationState? = null, ) { MaplibreMap( modifier = modifier, @@ -86,12 +98,18 @@ fun MaplibreMapContent( nodes = nodes, myNodeNum = myNodeNum, showPrecisionCircle = showPrecisionCircle, + cameraState = cameraState, onNodeClick = onNodeClick, ) // --- Waypoint markers --- if (showWaypoints) { - WaypointMarkerLayers(waypoints = waypoints) + WaypointMarkerLayers(waypoints = waypoints, onWaypointClick = onWaypointClick) + } + + // --- User location puck --- + if (locationState != null) { + LocationPuck(idPrefix = "user-location", locationState = locationState, cameraState = cameraState) } } @@ -103,14 +121,17 @@ fun MaplibreMapContent( } } -/** Node markers rendered as clustered circles and symbols using GeoJSON source. */ +/** Node markers rendered as clustered circles with per-node colors and short name labels. */ +@Suppress("LongMethod") @Composable private fun NodeMarkerLayers( nodes: List, myNodeNum: Int?, showPrecisionCircle: Boolean, + cameraState: CameraState, onNodeClick: (Int) -> Unit, ) { + val coroutineScope = rememberCoroutineScope() val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) } val nodesSource = @@ -120,7 +141,7 @@ private fun NodeMarkerLayers( GeoJsonOptions(cluster = true, clusterRadius = CLUSTER_RADIUS, clusterMinPoints = CLUSTER_MIN_POINTS), ) - // Cluster circles + // Cluster circles — tap to zoom in toward expansion CircleLayer( id = "node-clusters", source = nodesSource, @@ -130,6 +151,19 @@ private fun NodeMarkerLayers( opacity = const(CLUSTER_OPACITY), strokeWidth = const(2.dp), strokeColor = const(Color.White), + onClick = { features -> + val cluster = features.firstOrNull() ?: return@CircleLayer ClickResult.Pass + val target = (cluster.geometry as? Point)?.coordinates ?: return@CircleLayer ClickResult.Pass + coroutineScope.launch { + cameraState.animateTo( + cameraState.position.copy( + target = target, + zoom = cameraState.position.zoom + CLUSTER_ZOOM_INCREMENT, + ), + ) + } + ClickResult.Consume + }, ) // Cluster count labels @@ -142,13 +176,13 @@ private fun NodeMarkerLayers( textSize = const(1.2f.em), ) - // Individual node markers + // Individual node markers with per-node background color CircleLayer( id = "node-markers", source = nodesSource, filter = !feature.has("cluster"), radius = const(8.dp), - color = const(NodeMarkerColor), + color = feature["background_color"].convertToColor(const(NodeMarkerColor)), strokeWidth = const(2.dp), strokeColor = const(Color.White), onClick = { features -> @@ -162,23 +196,44 @@ private fun NodeMarkerLayers( }, ) - // Precision circles + // Short name labels below node markers + SymbolLayer( + id = "node-labels", + source = nodesSource, + filter = !feature.has("cluster"), + textField = feature["short_name"].asString(), + textSize = const(0.9f.em), + textOffset = offset(0f.em, LABEL_OFFSET_EM.em), + textColor = const(Color.DarkGray), + textAllowOverlap = const(true), + iconAllowOverlap = const(true), + ) + + // Precision circles — sized by precision_meters property if (showPrecisionCircle) { CircleLayer( id = "node-precision", source = nodesSource, filter = !feature.has("cluster"), - radius = const(40.dp), // TODO: scale by precision_meters and zoom - color = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), + radius = feature["precision_meters"].convertToNumber(const(0f)).dp, + color = + feature["background_color"].convertToColor( + const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), + ), + opacity = const(PRECISION_CIRCLE_FILL_ALPHA), strokeWidth = const(1.dp), - strokeColor = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)), + strokeColor = + feature["background_color"].convertToColor( + const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)), + ), + strokeOpacity = const(PRECISION_CIRCLE_STROKE_ALPHA), ) } } -/** Waypoint markers rendered as symbol layer with emoji icons. */ +/** Waypoint markers rendered as symbol layer with emoji icons and click handling. */ @Composable -private fun WaypointMarkerLayers(waypoints: Map) { +private fun WaypointMarkerLayers(waypoints: Map, onWaypointClick: (Int) -> Unit) { val featureCollection = remember(waypoints) { waypointsToFeatureCollection(waypoints) } val waypointSource = rememberGeoJsonSource(data = GeoJsonData.Features(featureCollection)) @@ -191,6 +246,15 @@ private fun WaypointMarkerLayers(waypoints: Map) { textSize = const(2f.em), textAllowOverlap = const(true), iconAllowOverlap = const(true), + onClick = { features -> + val waypointId = features.firstOrNull()?.properties?.get("waypoint_id")?.toString()?.toIntOrNull() + if (waypointId != null) { + onWaypointClick(waypointId) + ClickResult.Consume + } else { + ClickResult.Pass + } + }, ) // Waypoint name labels below emoji diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index 9c7e1220d0..0437728986 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -16,18 +16,23 @@ */ package org.meshtastic.feature.map.component +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.MaplibreMap +import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.proto.Position import org.maplibre.spatialk.geojson.Position as GeoPosition private const val DEFAULT_TRACK_ZOOM = 13.0 private const val COORDINATE_SCALE = 1e-7 +private const val BOUNDS_PADDING_DP = 48 /** * Embeddable position-track map showing a polyline with markers for the given positions. @@ -44,15 +49,28 @@ fun NodeTrackMap( selectedPositionTime: Int? = null, onPositionSelected: ((Int) -> Unit)? = null, ) { - val center = + val geoPositions = remember(positions) { - positions.firstOrNull()?.let { pos -> + positions.mapNotNull { pos -> val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null } } + val center = remember(geoPositions) { geoPositions.firstOrNull() } + + val boundingBox = + remember(geoPositions) { + if (geoPositions.size < 2) return@remember null + val lats = geoPositions.map { it.latitude } + val lngs = geoPositions.map { it.longitude } + BoundingBox( + southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()), + northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()), + ) + } + val cameraState = rememberCameraState( firstPosition = @@ -62,6 +80,11 @@ fun NodeTrackMap( ), ) + // Fit camera to bounds when the track has multiple positions. + LaunchedEffect(boundingBox) { + boundingBox?.let { cameraState.animateTo(boundingBox = it, padding = PaddingValues(BOUNDS_PADDING_DP.dp)) } + } + MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) { NodeTrackLayers( positions = positions, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index e198397c4d..9cf0fe1332 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -16,12 +16,16 @@ */ package org.meshtastic.feature.map.component +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.MaplibreMap +import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.proto.Position @@ -29,6 +33,7 @@ import org.maplibre.spatialk.geojson.Position as GeoPosition private const val DEFAULT_TRACEROUTE_ZOOM = 10.0 private const val COORDINATE_SCALE = 1e-7 +private const val BOUNDS_PADDING_DP = 64 /** * Embeddable traceroute map showing forward/return route polylines with hop markers. @@ -45,16 +50,28 @@ fun TracerouteMap( onMappableCountChanged: (shown: Int, total: Int) -> Unit, modifier: Modifier = Modifier, ) { - // Center the camera on the first node with a known position. - val center = + val geoPositions = remember(tracerouteNodePositions) { - tracerouteNodePositions.values.firstOrNull()?.let { pos -> + tracerouteNodePositions.values.mapNotNull { pos -> val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null } } + val center = remember(geoPositions) { geoPositions.firstOrNull() } + + val boundingBox = + remember(geoPositions) { + if (geoPositions.size < 2) return@remember null + val lats = geoPositions.map { it.latitude } + val lngs = geoPositions.map { it.longitude } + BoundingBox( + southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()), + northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()), + ) + } + val cameraState = rememberCameraState( firstPosition = @@ -64,6 +81,11 @@ fun TracerouteMap( ), ) + // Fit camera to bounds when the traceroute has multiple node positions. + LaunchedEffect(boundingBox) { + boundingBox?.let { cameraState.animateTo(boundingBox = it, padding = PaddingValues(BOUNDS_PADDING_DP.dp)) } + } + MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) { TracerouteLayers( overlay = tracerouteOverlay, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index a28c766271..622703f057 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -54,8 +54,8 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature put("via_mqtt", node.viaMqtt) put("snr", node.snr.toDouble()) put("rssi", node.rssi) - put("foreground_color", colors.first) - put("background_color", colors.second) + put("foreground_color", intToHexColor(colors.first)) + put("background_color", intToHexColor(colors.second)) put("has_precision", (pos.precision_bits ?: 0) in MIN_PRECISION_BITS..MAX_PRECISION_BITS) put("precision_meters", precisionBitsToMeters(pos.precision_bits ?: 0)) } @@ -159,6 +159,14 @@ private const val HALF_SHIFT = 10 private const val HIGH_SURROGATE_BASE = 0xD800 private const val LOW_SURROGATE_BASE = 0xDC00 private const val SURROGATE_MASK = 0x3FF +private const val HEX_COLOR_MASK = 0xFFFFFF + +/** Convert an ARGB color integer to a hex color string (e.g. "#FF6750A4") for MapLibre expressions. */ +@Suppress("MagicNumber") +internal fun intToHexColor(argb: Int): String { + val rgb = argb and HEX_COLOR_MASK + return "#${rgb.toString(16).padStart(6, '0').uppercase()}" +} /** Convert a Unicode code point integer to its emoji string representation. */ internal fun convertIntToEmoji(codePoint: Int): String = try { diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt new file mode 100644 index 0000000000..d98dc681a1 --- /dev/null +++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt @@ -0,0 +1,23 @@ +/* + * 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 androidx.compose.runtime.Composable +import org.maplibre.compose.location.LocationProvider +import org.maplibre.compose.location.rememberDefaultLocationProvider + +@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = rememberDefaultLocationProvider() diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt new file mode 100644 index 0000000000..717deb534d --- /dev/null +++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt @@ -0,0 +1,23 @@ +/* + * 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 androidx.compose.runtime.Composable +import org.maplibre.compose.location.LocationProvider + +/** Desktop has no location provider — return null so the UI disables location tracking. */ +@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = null From 1fb3f2fe53563642e8191f532e7d0a492ab34c53 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 12 Apr 2026 19:26:19 -0500 Subject: [PATCH 03/23] chore: ignore Eclipse/Buildship IDE metadata files --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 08af968999..c854a8f36c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,13 @@ docs/screenshots/ # VS code .vscode/settings.json +# Eclipse / Buildship +.project +.classpath +.settings/ +**/bin/main/ +**/bin/test/ + # Secrets /secrets.properties /fastlane/play-store-credentials.json From 6167d32d261005aeccb0648cc3cad8dbb6c55cd2 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 12 Apr 2026 20:52:08 -0500 Subject: [PATCH 04/23] =?UTF-8?q?feat(map):=20add=20maplibre-compose=20API?= =?UTF-8?q?=20enhancements=20=E2=80=94=20scale=20bar,=20bearing=20tracking?= =?UTF-8?q?,=20gestures,=20hillshade,=20offline=20tiles,=20map=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leverage underused maplibre-compose 0.12.1 APIs to improve UX parity: - OrnamentOptions: enable built-in scale bar on all map screens - GestureOptions: per-screen gesture control (Standard, PositionLocked, RotationLocked, ZoomOnly) based on tracking state - BearingUpdate 3-state cycling: Off → Track+Bearing → Track+North → Off with CameraMoveReason.GESTURE auto-cancel - Offline tile downloads: expect/actual OfflineManagerFactory with Android/iOS actuals using rememberOfflineManager + OfflinePackListItem - HillshadeLayer + RasterDemSource: terrain visualization with free AWS Terrarium tiles when Terrain style is selected - Map loading callbacks: onMapLoadFinished/onMapLoadFailed propagated - Map styles: all 5 styles now use distinct URIs (Liberty, Positron, Bright, Americana, Fiord) - NodeTrackLayers: fix selected highlight filter expression - LocationProviderFactory: check permissions before calling rememberDefaultLocationProvider to prevent PermissionException --- .../feature/map/LocationProviderFactory.kt | 17 ++- .../feature/map/OfflineManagerFactory.kt | 127 ++++++++++++++++++ .../org/meshtastic/feature/map/MapScreen.kt | 63 ++++++++- .../feature/map/OfflineManagerFactory.kt | 35 +++++ .../map/component/MapControlsOverlay.kt | 12 +- .../map/component/MaplibreMapContent.kt | 23 ++++ .../feature/map/component/NodeTrackLayers.kt | 4 + .../feature/map/component/NodeTrackMap.kt | 11 +- .../feature/map/component/TracerouteMap.kt | 11 +- .../meshtastic/feature/map/model/MapStyle.kt | 19 ++- .../feature/map/OfflineManagerFactory.kt | 123 +++++++++++++++++ .../feature/map/OfflineManagerFactory.kt | 27 ++++ 12 files changed, 453 insertions(+), 19 deletions(-) create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt create mode 100644 feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt create mode 100644 feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt index d98dc681a1..f26228c792 100644 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt @@ -16,8 +16,23 @@ */ package org.meshtastic.feature.map +import android.Manifest import androidx.compose.runtime.Composable +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState import org.maplibre.compose.location.LocationProvider import org.maplibre.compose.location.rememberDefaultLocationProvider -@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = rememberDefaultLocationProvider() +@OptIn(ExperimentalPermissionsApi::class) +@Composable +actual fun rememberLocationProviderOrNull(): LocationProvider? { + val locationPermissions = + rememberMultiplePermissionsState( + permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), + ) + return if (locationPermissions.allPermissionsGranted) { + rememberDefaultLocationProvider() + } else { + null + } +} diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt new file mode 100644 index 0000000000..9b23f70038 --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt @@ -0,0 +1,127 @@ +/* + * 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 androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.unit.dp +import kotlinx.coroutines.launch +import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.material3.OfflinePackListItem +import org.maplibre.compose.offline.OfflinePackDefinition +import org.maplibre.compose.offline.rememberOfflineManager +import org.meshtastic.core.ui.icon.CloudDownload +import org.meshtastic.core.ui.icon.MeshtasticIcons + +@Composable actual fun isOfflineManagerAvailable(): Boolean = true + +@Suppress("LongMethod") +@Composable +actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { + val offlineManager = rememberOfflineManager() + val coroutineScope = rememberCoroutineScope() + var showDialog by remember { mutableStateOf(false) } + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text("Offline Maps") }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + // Download button for current viewport + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .clickable { + coroutineScope.launch { + val projection = cameraState.awaitProjection() + val bounds = projection.queryVisibleBoundingBox() + val pack = + offlineManager.create( + definition = + OfflinePackDefinition.TilePyramid( + styleUrl = styleUri, + bounds = bounds, + ), + metadata = "Region".encodeToByteArray(), + ) + offlineManager.resume(pack) + } + } + .padding(vertical = 12.dp), + ) { + Icon( + imageVector = MeshtasticIcons.CloudDownload, + contentDescription = "Download", + modifier = Modifier.padding(end = 16.dp), + ) + Column { + Text(text = "Download visible region", style = MaterialTheme.typography.bodyLarge) + Text( + text = "Saves tiles for offline use", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Existing packs + if (offlineManager.packs.isNotEmpty()) { + Text( + text = "Downloaded Regions", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) + offlineManager.packs.toList().forEach { pack -> + key(pack.hashCode()) { + OfflinePackListItem(pack = pack, offlineManager = offlineManager) { + Text(pack.metadata?.decodeToString().orEmpty().ifBlank { "Unnamed Region" }) + } + } + } + } + } + }, + confirmButton = { TextButton(onClick = { showDialog = false }) { Text("Done") } }, + ) + } + + // Expose the toggle via a side effect — the parent screen will call this + // by rendering OfflineMapContent and using the showDialog state + IconButton(onClick = { showDialog = true }) { + Icon(imageVector = MeshtasticIcons.CloudDownload, contentDescription = "Offline Maps") + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index d610935e0d..f322b2ccbf 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -32,11 +32,14 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource +import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.location.BearingUpdate import org.maplibre.compose.location.LocationTrackingEffect import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberUserLocationState +import org.maplibre.compose.map.GestureOptions import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map @@ -46,6 +49,7 @@ import org.meshtastic.feature.map.component.MapControlsOverlay import org.meshtastic.feature.map.component.MapFilterDropdown import org.meshtastic.feature.map.component.MapStyleSelector import org.meshtastic.feature.map.component.MaplibreMapContent +import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.proto.Waypoint import org.maplibre.spatialk.geojson.Position as GeoPosition @@ -90,12 +94,27 @@ fun MapScreen( val scope = rememberCoroutineScope() - // Location tracking state + // Location tracking state: 3-mode cycling (Off → Track → TrackBearing → Off) var isLocationTrackingEnabled by remember { mutableStateOf(false) } + var bearingUpdate by remember { mutableStateOf(BearingUpdate.TRACK_LOCATION) } val locationProvider = rememberLocationProviderOrNull() val locationState = rememberUserLocationState(locationProvider ?: rememberNullLocationProvider()) val locationAvailable = locationProvider != null + // Derive gesture options from location tracking state + val gestureOptions = + remember(isLocationTrackingEnabled, bearingUpdate) { + if (isLocationTrackingEnabled) { + when (bearingUpdate) { + BearingUpdate.IGNORE -> GestureOptions.PositionLocked + BearingUpdate.ALWAYS_NORTH -> GestureOptions.ZoomOnly + BearingUpdate.TRACK_LOCATION -> GestureOptions.ZoomOnly + } + } else { + GestureOptions.Standard + } + } + // Animate to waypoint when waypointId is provided (deep-link) val selectedWaypointId by viewModel.selectedWaypointId.collectAsStateWithLifecycle() LaunchedEffect(selectedWaypointId, waypoints) { @@ -148,6 +167,7 @@ fun MapScreen( myNodeNum = viewModel.myNodeNum, showWaypoints = filterState.showWaypoints, showPrecisionCircle = filterState.showPrecisionCircle, + showHillshade = selectedMapStyle == MapStyle.Terrain, onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) }, onMapLongClick = { position -> longPressPosition = position @@ -155,6 +175,7 @@ fun MapScreen( showWaypointDialog = true }, modifier = Modifier.fillMaxSize(), + gestureOptions = gestureOptions, onCameraMoved = { position -> viewModel.saveCameraPosition(position) }, onWaypointClick = { wpId -> editingWaypointId = wpId @@ -166,8 +187,19 @@ fun MapScreen( // Auto-pan camera when location tracking is enabled if (locationAvailable) { - LocationTrackingEffect(locationState = locationState, enabled = isLocationTrackingEnabled) { - cameraState.updateFromLocation() + LocationTrackingEffect( + locationState = locationState, + enabled = isLocationTrackingEnabled, + trackBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION, + ) { + cameraState.updateFromLocation(updateBearing = bearingUpdate) + } + + // Cancel tracking when user manually pans the map + LaunchedEffect(cameraState.moveReason) { + if (cameraState.moveReason == CameraMoveReason.GESTURE && isLocationTrackingEnabled) { + isLocationTrackingEnabled = false + } } } @@ -176,6 +208,7 @@ fun MapScreen( modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues), bearing = cameraState.position.bearing.toFloat(), onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } }, + followPhoneBearing = isLocationTrackingEnabled && bearingUpdate == BearingUpdate.TRACK_LOCATION, filterDropdownContent = { MapFilterDropdown( expanded = filterMenuExpanded, @@ -190,8 +223,30 @@ fun MapScreen( mapTypeContent = { MapStyleSelector(selectedStyle = selectedMapStyle, onSelectStyle = viewModel::selectMapStyle) }, + layersContent = { OfflineMapContent(styleUri = selectedMapStyle.styleUri, cameraState = cameraState) }, isLocationTrackingEnabled = isLocationTrackingEnabled, - onToggleLocationTracking = { isLocationTrackingEnabled = !isLocationTrackingEnabled }, + isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION, + onToggleLocationTracking = { + if (!isLocationTrackingEnabled) { + // Off → Track with bearing + bearingUpdate = BearingUpdate.TRACK_LOCATION + isLocationTrackingEnabled = true + } else { + when (bearingUpdate) { + BearingUpdate.TRACK_LOCATION -> { + // TrackBearing → TrackNorth + bearingUpdate = BearingUpdate.ALWAYS_NORTH + } + BearingUpdate.ALWAYS_NORTH -> { + // TrackNorth → Off + isLocationTrackingEnabled = false + } + BearingUpdate.IGNORE -> { + isLocationTrackingEnabled = false + } + } + } + }, ) } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt new file mode 100644 index 0000000000..ee130055ff --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt @@ -0,0 +1,35 @@ +/* + * 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 androidx.compose.runtime.Composable + +/** + * Returns `true` if the platform supports offline map tile management. + * - Android: `true` (backed by MapLibre Native). + * - iOS: `true` (backed by MapLibre Native). + * - Desktop/JS: `false` (no offline support). + */ +@Composable expect fun isOfflineManagerAvailable(): Boolean + +/** + * Renders platform-specific offline map management UI if the platform supports it. The composable receives the current + * style URI and [cameraState] for downloading the visible region. + * + * On unsupported platforms, this is a no-op. + */ +@Composable expect fun OfflineMapContent(styleUri: String, cameraState: org.maplibre.compose.camera.CameraState) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index 1445424b3f..e49fcbd132 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -40,6 +40,7 @@ import org.meshtastic.core.ui.icon.LocationDisabled import org.meshtastic.core.ui.icon.MapCompass import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MyLocation +import org.meshtastic.core.ui.icon.NearMe import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.icon.Tune import org.meshtastic.core.ui.theme.StatusColors.StatusRed @@ -72,6 +73,7 @@ fun MapControlsOverlay( mapTypeContent: @Composable () -> Unit = {}, layersContent: @Composable () -> Unit = {}, isLocationTrackingEnabled: Boolean = false, + isTrackingBearing: Boolean = false, onToggleLocationTracking: () -> Unit = {}, showRefresh: Boolean = false, isRefreshing: Boolean = false, @@ -116,10 +118,16 @@ fun MapControlsOverlay( } } - // Location tracking button + // Location tracking button — 3 states: Off (MyLocation), Tracking (LocationDisabled), TrackingBearing (NearMe) MapButton( - icon = if (isLocationTrackingEnabled) MeshtasticIcons.LocationDisabled else MeshtasticIcons.MyLocation, + icon = + when { + !isLocationTrackingEnabled -> MeshtasticIcons.MyLocation + isTrackingBearing -> MeshtasticIcons.NearMe + else -> MeshtasticIcons.LocationDisabled + }, contentDescription = stringResource(Res.string.toggle_my_position), + iconTint = if (isLocationTrackingEnabled) MaterialTheme.colorScheme.primary else null, onClick = onToggleLocationTracking, ) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index f749d3a893..dae42ed61f 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -36,13 +36,19 @@ import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.dsl.offset import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.HillshadeLayer import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.location.LocationPuck import org.maplibre.compose.location.UserLocationState +import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.map.OrnamentOptions import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonOptions +import org.maplibre.compose.sources.RasterDemEncoding import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.sources.rememberRasterDemSource import org.maplibre.compose.style.BaseStyle import org.maplibre.compose.util.ClickResult import org.maplibre.spatialk.geojson.Point @@ -60,6 +66,10 @@ private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f private const val CLUSTER_OPACITY = 0.85f private const val LABEL_OFFSET_EM = 1.5f private const val CLUSTER_ZOOM_INCREMENT = 2.0 +private const val HILLSHADE_EXAGGERATION = 0.5f + +/** Free Terrain Tiles (Terrarium encoding) hosted on AWS. No API key required. */ +private val TERRAIN_TILES = listOf("https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png") /** * Main map content composable using MapLibre Compose Multiplatform. @@ -76,23 +86,36 @@ fun MaplibreMapContent( myNodeNum: Int?, showWaypoints: Boolean, showPrecisionCircle: Boolean, + showHillshade: Boolean, onNodeClick: (Int) -> Unit, onMapLongClick: (GeoPosition) -> Unit, modifier: Modifier = Modifier, + gestureOptions: GestureOptions = GestureOptions.Standard, onCameraMoved: (CameraPosition) -> Unit = {}, onWaypointClick: (Int) -> Unit = {}, + onMapLoadFinished: () -> Unit = {}, + onMapLoadFailed: (String?) -> Unit = {}, locationState: UserLocationState? = null, ) { MaplibreMap( modifier = modifier, baseStyle = baseStyle, cameraState = cameraState, + options = MapOptions(gestureOptions = gestureOptions, ornamentOptions = OrnamentOptions.AllEnabled), onMapLongClick = { position, _ -> onMapLongClick(position) ClickResult.Consume }, + onMapLoadFinished = onMapLoadFinished, + onMapLoadFailed = onMapLoadFailed, onFrame = {}, ) { + // --- Terrain hillshade overlay --- + if (showHillshade) { + val demSource = rememberRasterDemSource(tiles = TERRAIN_TILES, encoding = RasterDemEncoding.Terrarium) + HillshadeLayer(id = "terrain-hillshade", source = demSource, exaggeration = const(HILLSHADE_EXAGGERATION)) + } + // --- Node markers with clustering --- NodeMarkerLayers( nodes = nodes, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt index 94341d419a..a4c8fdd5df 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt @@ -20,7 +20,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import org.maplibre.compose.expressions.dsl.asNumber import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.eq +import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.LineLayer import org.maplibre.compose.sources.GeoJsonData @@ -93,6 +96,7 @@ fun NodeTrackLayers( CircleLayer( id = "node-track-selected", source = pointsSource, + filter = feature["time"].asNumber() eq const(selectedPositionTime.toFloat()), radius = const(10.dp), color = const(SelectedPointColor), // Red strokeWidth = const(2.dp), diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index 0437728986..c919b2afa1 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -24,7 +24,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.map.OrnamentOptions import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.proto.Position @@ -85,7 +88,13 @@ fun NodeTrackMap( boundingBox?.let { cameraState.animateTo(boundingBox = it, padding = PaddingValues(BOUNDS_PADDING_DP.dp)) } } - MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) { + MaplibreMap( + modifier = modifier, + baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), + cameraState = cameraState, + options = + MapOptions(gestureOptions = GestureOptions.RotationLocked, ornamentOptions = OrnamentOptions.AllEnabled), + ) { NodeTrackLayers( positions = positions, selectedPositionTime = selectedPositionTime, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index 9cf0fe1332..7dbb9b0296 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -24,7 +24,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.map.OrnamentOptions import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.feature.map.model.MapStyle @@ -86,7 +89,13 @@ fun TracerouteMap( boundingBox?.let { cameraState.animateTo(boundingBox = it, padding = PaddingValues(BOUNDS_PADDING_DP.dp)) } } - MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) { + MaplibreMap( + modifier = modifier, + baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), + cameraState = cameraState, + options = + MapOptions(gestureOptions = GestureOptions.RotationLocked, ornamentOptions = OrnamentOptions.AllEnabled), + ) { TracerouteLayers( overlay = tracerouteOverlay, nodePositions = tracerouteNodePositions, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt index 21f063433c..334521d68d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt @@ -28,24 +28,23 @@ import org.meshtastic.core.resources.map_style_terrain /** * Predefined map tile styles available in the app. * - * Uses free tile sources that do not require API keys. Custom XYZ tile URLs and offline sources can be configured - * separately via [MapLayerItem]. + * Uses free tile sources that do not require API keys. All styles are vector-based and work across platforms. */ enum class MapStyle(val label: StringResource, val styleUri: String) { /** OpenStreetMap default tiles via OpenFreeMap Liberty style. */ OpenStreetMap(label = Res.string.map_style_osm, styleUri = "https://tiles.openfreemap.org/styles/liberty"), - /** Satellite imagery — uses OpenFreeMap with a raster overlay switcher. */ - Satellite(label = Res.string.map_style_satellite, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + /** Clean, light cartographic style via OpenFreeMap Positron. */ + Satellite(label = Res.string.map_style_satellite, styleUri = "https://tiles.openfreemap.org/styles/positron"), - /** Terrain style. */ - Terrain(label = Res.string.map_style_terrain, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + /** Topographic style via OpenFreeMap Bright. */ + Terrain(label = Res.string.map_style_terrain, styleUri = "https://tiles.openfreemap.org/styles/bright"), - /** Satellite + labels hybrid. */ - Hybrid(label = Res.string.map_style_hybrid, styleUri = "https://tiles.openfreemap.org/styles/liberty"), + /** US road-map style via Americana. */ + Hybrid(label = Res.string.map_style_hybrid, styleUri = "https://americanamap.org/style.json"), - /** Dark mode style. */ - Dark(label = Res.string.map_style_dark, styleUri = "https://tiles.openfreemap.org/styles/bright"), + /** Dark mode style via OpenFreeMap Bright (dark palette). */ + Dark(label = Res.string.map_style_dark, styleUri = "https://tiles.openfreemap.org/styles/fiord"), ; fun toBaseStyle(): BaseStyle = BaseStyle.Uri(styleUri) diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt new file mode 100644 index 0000000000..819cf17084 --- /dev/null +++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt @@ -0,0 +1,123 @@ +/* + * 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 androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.unit.dp +import kotlinx.coroutines.launch +import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.material3.OfflinePackListItem +import org.maplibre.compose.offline.OfflinePackDefinition +import org.maplibre.compose.offline.rememberOfflineManager +import org.meshtastic.core.ui.icon.CloudDownload +import org.meshtastic.core.ui.icon.MeshtasticIcons + +@Composable actual fun isOfflineManagerAvailable(): Boolean = true + +@Suppress("LongMethod") +@Composable +actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { + val offlineManager = rememberOfflineManager() + val coroutineScope = rememberCoroutineScope() + var showDialog by remember { mutableStateOf(false) } + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text("Offline Maps") }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .clickable { + coroutineScope.launch { + val projection = cameraState.awaitProjection() + val bounds = projection.queryVisibleBoundingBox() + val pack = + offlineManager.create( + definition = + OfflinePackDefinition.TilePyramid( + styleUrl = styleUri, + bounds = bounds, + ), + metadata = "Region".encodeToByteArray(), + ) + offlineManager.resume(pack) + } + } + .padding(vertical = 12.dp), + ) { + Icon( + imageVector = MeshtasticIcons.CloudDownload, + contentDescription = "Download", + modifier = Modifier.padding(end = 16.dp), + ) + Column { + Text(text = "Download visible region", style = MaterialTheme.typography.bodyLarge) + Text( + text = "Saves tiles for offline use", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (offlineManager.packs.isNotEmpty()) { + Text( + text = "Downloaded Regions", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) + offlineManager.packs.toList().forEach { pack -> + key(pack.hashCode()) { + OfflinePackListItem(pack = pack, offlineManager = offlineManager) { + Text(pack.metadata?.decodeToString().orEmpty().ifBlank { "Unnamed Region" }) + } + } + } + } + } + }, + confirmButton = { TextButton(onClick = { showDialog = false }) { Text("Done") } }, + ) + } + + IconButton(onClick = { showDialog = true }) { + Icon(imageVector = MeshtasticIcons.CloudDownload, contentDescription = "Offline Maps") + } +} diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt new file mode 100644 index 0000000000..bda1f9aa12 --- /dev/null +++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt @@ -0,0 +1,27 @@ +/* + * 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 androidx.compose.runtime.Composable +import org.maplibre.compose.camera.CameraState + +@Composable actual fun isOfflineManagerAvailable(): Boolean = false + +@Composable +actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { + // Offline map management is not available on Desktop. +} From 2725a5f3ae185e0a08b76782d5081fe6dee20206 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 12 Apr 2026 22:26:30 -0500 Subject: [PATCH 05/23] fix(map): delete leftover fdroid OSMDroid cluster Java files breaking lint The MarkerClusterer, RadiusMarkerClusterer, and StaticCluster Java files under app/src/fdroid/java/ were missed during the MapLibre migration and still referenced the removed osmdroid dependency, causing lintFdroidDebug to fail on CI. --- .../app/map/cluster/MarkerClusterer.java | 216 ------------------ .../map/cluster/RadiusMarkerClusterer.java | 213 ----------------- .../app/map/cluster/StaticCluster.java | 85 ------- 3 files changed, 514 deletions(-) delete mode 100644 androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java delete mode 100644 androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java delete mode 100644 androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java diff --git a/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java b/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java deleted file mode 100644 index 38e51da529..0000000000 --- a/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java +++ /dev/null @@ -1,216 +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.app.map.cluster; - -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Point; -import android.view.MotionEvent; - -import org.meshtastic.app.map.model.MarkerWithLabel; - -import org.osmdroid.util.BoundingBox; -import org.osmdroid.views.MapView; -import org.osmdroid.views.overlay.Overlay; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.ListIterator; - -/** - * An overlay allowing to perform markers clustering. - * Usage: put your markers inside with add(Marker), and add the MarkerClusterer to the map overlays. - * Depending on the zoom level, markers will be displayed separately, or grouped as a single Marker.
- * - * This abstract class provides the framework. Sub-classes have to implement the clustering algorithm, - * and the rendering of a cluster. - * - * @author M.Kergall - * - */ -public abstract class MarkerClusterer extends Overlay { - - /** impossible value for zoom level, to force clustering */ - protected static final int FORCE_CLUSTERING = -1; - - protected ArrayList mItems = new ArrayList(); - protected Point mPoint = new Point(); - protected ArrayList mClusters = new ArrayList(); - protected int mLastZoomLevel; - protected Bitmap mClusterIcon; - protected String mName, mDescription; - - // abstract methods: - - /** clustering algorithm */ - public abstract ArrayList clusterer(MapView mapView); - /** Build the marker for a cluster. */ - public abstract MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView); - /** build clusters markers to be used at next draw */ - public abstract void renderer(ArrayList clusters, Canvas canvas, MapView mapView); - - public MarkerClusterer() { - super(); - mLastZoomLevel = FORCE_CLUSTERING; - } - - public void setName(String name){ - mName = name; - } - - public String getName(){ - return mName; - } - - public void setDescription(String description){ - mDescription = description; - } - - public String getDescription(){ - return mDescription; - } - - /** Set the cluster icon to be drawn when a cluster contains more than 1 marker. - * If not set, default will be the default osmdroid marker icon (which is really inappropriate as a cluster icon). */ - public void setIcon(Bitmap icon){ - mClusterIcon = icon; - } - - /** Add the Marker. - * Important: Markers added in a MarkerClusterer should not be added in the map overlays. */ - public void add(MarkerWithLabel marker){ - mItems.add(marker); - } - - /** Force a rebuild of clusters at next draw, even without a zooming action. - * Should be done when you changed the content of a MarkerClusterer. */ - public void invalidate(){ - mLastZoomLevel = FORCE_CLUSTERING; - } - - /** @return the Marker at id (starting at 0) */ - public MarkerWithLabel getItem(int id){ - return mItems.get(id); - } - - /** @return the list of Markers. */ - public ArrayList getItems(){ - return mItems; - } - - protected void hideInfoWindows(){ - for (MarkerWithLabel m : mItems){ - if (m.isInfoWindowShown()) - m.closeInfoWindow(); - } - } - - @Override public void draw(Canvas canvas, MapView mapView, boolean shadow) { - if (shadow) - return; - //if zoom has changed and mapView is now stable, rebuild clusters: - int zoomLevel = mapView.getZoomLevel(); - if (zoomLevel != mLastZoomLevel && !mapView.isAnimating()){ - hideInfoWindows(); - mClusters = clusterer(mapView); - renderer(mClusters, canvas, mapView); - mLastZoomLevel = zoomLevel; - } - - for (StaticCluster cluster:mClusters){ - MarkerWithLabel marker = cluster.getMarker(); - marker.draw(canvas, mapView, false); - } - } - - public Iterable reversedClusters() { - return new Iterable() { - @Override - public Iterator iterator() { - final ListIterator i = mClusters.listIterator(mClusters.size()); - return new Iterator() { - @Override - public boolean hasNext() { - return i.hasPrevious(); - } - - @Override - public StaticCluster next() { - return i.previous(); - } - - @Override - public void remove() { - i.remove(); - } - }; - } - }; - } - - @Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){ - for (final StaticCluster cluster : reversedClusters()) { - if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) - return true; - } - return false; - } - - @Override public boolean onLongPress(final MotionEvent event, final MapView mapView) { - for (final StaticCluster cluster : reversedClusters()) { - if (cluster.getMarker().onLongPress(event, mapView)) - return true; - } - return false; - } - - @Override public boolean onTouchEvent(final MotionEvent event, final MapView mapView) { - for (StaticCluster cluster : reversedClusters()) { - if (cluster.getMarker().onTouchEvent(event, mapView)) - return true; - } - return false; - } - - @Override public boolean onDoubleTap(final MotionEvent event, final MapView mapView) { - for (final StaticCluster cluster : reversedClusters()) { - if (cluster.getMarker().onDoubleTap(event, mapView)) - return true; - } - return false; - } - - @Override public BoundingBox getBounds(){ - if (mItems.size() == 0) - return null; - double minLat = Double.MAX_VALUE; - double minLon = Double.MAX_VALUE; - double maxLat = -Double.MAX_VALUE; - double maxLon = -Double.MAX_VALUE; - for (final MarkerWithLabel item : mItems) { - final double latitude = item.getPosition().getLatitude(); - final double longitude = item.getPosition().getLongitude(); - minLat = Math.min(minLat, latitude); - minLon = Math.min(minLon, longitude); - maxLat = Math.max(maxLat, latitude); - maxLon = Math.max(maxLon, longitude); - } - return new BoundingBox(maxLat, maxLon, minLat, minLon); - } - -} diff --git a/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java b/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java deleted file mode 100644 index e2710352ab..0000000000 --- a/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java +++ /dev/null @@ -1,213 +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.app.map.cluster; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.view.MotionEvent; - -import org.meshtastic.app.map.model.MarkerWithLabel; - -import org.osmdroid.bonuspack.R; -import org.osmdroid.util.BoundingBox; -import org.osmdroid.util.GeoPoint; -import org.osmdroid.views.MapView; - -import java.util.ArrayList; -import java.util.Iterator; - -/** - * Radius-based Clustering algorithm: - * create a cluster using the first point from the cloned list. - * All points that are found within the neighborhood are added to this cluster. - * Then all the neighbors and the main point are removed from the list of points. - * It continues until the list is empty. - * - * Largely inspired from GridMarkerClusterer by M.Kergall - * - * @author sidorovroman92@gmail.com - */ - -public class RadiusMarkerClusterer extends MarkerClusterer { - - protected int mMaxClusteringZoomLevel = 7; - protected int mRadiusInPixels = 100; - protected double mRadiusInMeters; - protected Paint mTextPaint; - private ArrayList mClonedMarkers; - protected boolean mAnimated; - int mDensityDpi; - - /** cluster icon anchor */ - public float mAnchorU = MarkerWithLabel.ANCHOR_CENTER, mAnchorV = MarkerWithLabel.ANCHOR_CENTER; - /** anchor point to draw the number of markers inside the cluster icon */ - public float mTextAnchorU = MarkerWithLabel.ANCHOR_CENTER, mTextAnchorV = MarkerWithLabel.ANCHOR_CENTER; - - public RadiusMarkerClusterer(Context ctx) { - super(); - mTextPaint = new Paint(); - mTextPaint.setColor(Color.WHITE); - mTextPaint.setTextSize(15 * ctx.getResources().getDisplayMetrics().density); - mTextPaint.setFakeBoldText(true); - mTextPaint.setTextAlign(Paint.Align.CENTER); - mTextPaint.setAntiAlias(true); - Drawable clusterIconD = ctx.getResources().getDrawable(R.drawable.marker_cluster); - Bitmap clusterIcon = ((BitmapDrawable) clusterIconD).getBitmap(); - setIcon(clusterIcon); - mAnimated = true; - mDensityDpi = ctx.getResources().getDisplayMetrics().densityDpi; - } - - /** If you want to change the default text paint (color, size, font) */ - public Paint getTextPaint(){ - return mTextPaint; - } - - /** Set the radius of clustering in pixels. Default is 100px. */ - public void setRadius(int radius){ - mRadiusInPixels = radius; - } - - /** Set max zoom level with clustering. When zoom is higher or equal to this level, clustering is disabled. - * You can put a high value to disable this feature. */ - public void setMaxClusteringZoomLevel(int zoom){ - mMaxClusteringZoomLevel = zoom; - } - - /** Radius-Based clustering algorithm */ - @Override public ArrayList clusterer(MapView mapView) { - - ArrayList clusters = new ArrayList(); - convertRadiusToMeters(mapView); - - mClonedMarkers = new ArrayList(mItems); //shallow copy - while (!mClonedMarkers.isEmpty()) { - MarkerWithLabel m = mClonedMarkers.get(0); - StaticCluster cluster = createCluster(m, mapView); - clusters.add(cluster); - } - return clusters; - } - - private StaticCluster createCluster(MarkerWithLabel m, MapView mapView) { - GeoPoint clusterPosition = m.getPosition(); - - StaticCluster cluster = new StaticCluster(clusterPosition); - cluster.add(m); - - mClonedMarkers.remove(m); - - if (mapView.getZoomLevel() > mMaxClusteringZoomLevel) { - //above max level => block clustering: - return cluster; - } - - Iterator it = mClonedMarkers.iterator(); - while (it.hasNext()) { - MarkerWithLabel neighbor = it.next(); - double distance = clusterPosition.distanceToAsDouble(neighbor.getPosition()); - if (distance <= mRadiusInMeters) { - cluster.add(neighbor); - it.remove(); - } - } - - return cluster; - } - - @Override public MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView) { - MarkerWithLabel m = new MarkerWithLabel(mapView, "", null); - m.setPosition(cluster.getPosition()); - m.setInfoWindow(null); - m.setAnchor(mAnchorU, mAnchorV); - - Bitmap finalIcon = Bitmap.createBitmap(mClusterIcon.getScaledWidth(mDensityDpi), - mClusterIcon.getScaledHeight(mDensityDpi), mClusterIcon.getConfig()); - Canvas iconCanvas = new Canvas(finalIcon); - iconCanvas.drawBitmap(mClusterIcon, 0, 0, null); - String text = "" + cluster.getSize(); - int textHeight = (int) (mTextPaint.descent() + mTextPaint.ascent()); - iconCanvas.drawText(text, - mTextAnchorU * finalIcon.getWidth(), - mTextAnchorV * finalIcon.getHeight() - textHeight / 2, - mTextPaint); - m.setIcon(new BitmapDrawable(mapView.getContext().getResources(), finalIcon)); - - return m; - } - - @Override public void renderer(ArrayList clusters, Canvas canvas, MapView mapView) { - for (StaticCluster cluster : clusters) { - if (cluster.getSize() == 1) { - //cluster has only 1 marker => use it as it is: - cluster.setMarker(cluster.getItem(0)); - } else { - //only draw 1 Marker at Cluster center, displaying number of Markers contained - MarkerWithLabel m = buildClusterMarker(cluster, mapView); - cluster.setMarker(m); - } - } - } - - private void convertRadiusToMeters(MapView mapView) { - - Rect mScreenRect = mapView.getIntrinsicScreenRect(null); - - int screenWidth = mScreenRect.right - mScreenRect.left; - int screenHeight = mScreenRect.bottom - mScreenRect.top; - - BoundingBox bb = mapView.getBoundingBox(); - - double diagonalInMeters = bb.getDiagonalLengthInMeters(); - double diagonalInPixels = Math.sqrt(screenWidth * screenWidth + screenHeight * screenHeight); - double metersInPixel = diagonalInMeters / diagonalInPixels; - - mRadiusInMeters = mRadiusInPixels * metersInPixel; - } - - public void setAnimation(boolean animate){ - mAnimated = animate; - } - - public void zoomOnCluster(MapView mapView, StaticCluster cluster){ - BoundingBox bb = cluster.getBoundingBox(); - if (bb.getLatNorth()!=bb.getLatSouth() || bb.getLonEast()!=bb.getLonWest()) { - bb = bb.increaseByScale(2.3f); - mapView.zoomToBoundingBox(bb, true); - } else //all points exactly at the same place: - mapView.setExpectedCenter(bb.getCenterWithDateLine()); - } - - @Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){ - for (final StaticCluster cluster : reversedClusters()) { - if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) { - if (mAnimated && cluster.getSize() > 1) - zoomOnCluster(mapView, cluster); - return true; - } - } - return false; - } - -} diff --git a/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java b/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java deleted file mode 100644 index 324a34b529..0000000000 --- a/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/StaticCluster.java +++ /dev/null @@ -1,85 +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.app.map.cluster; - -import org.meshtastic.app.map.model.MarkerWithLabel; - -import org.osmdroid.util.BoundingBox; -import org.osmdroid.util.GeoPoint; - -import java.util.ArrayList; - -/** - * Cluster of Markers. - * @author M.Kergall - */ -public class StaticCluster { - protected final ArrayList mItems = new ArrayList(); - protected GeoPoint mCenter; - protected MarkerWithLabel mMarker; - - public StaticCluster(GeoPoint center) { - mCenter = center; - } - - public void setPosition(GeoPoint center){ - mCenter = center; - } - - public GeoPoint getPosition() { - return mCenter; - } - - public int getSize() { - return mItems.size(); - } - - public MarkerWithLabel getItem(int index) { - return mItems.get(index); - } - - public boolean add(MarkerWithLabel t) { - return mItems.add(t); - } - - /** set the Marker to be displayed for this cluster */ - public void setMarker(MarkerWithLabel marker){ - mMarker = marker; - } - - /** @return the Marker to be displayed for this cluster */ - public MarkerWithLabel getMarker(){ - return mMarker; - } - - public BoundingBox getBoundingBox(){ - if (getSize()==0) - return null; - GeoPoint p = getItem(0).getPosition(); - BoundingBox bb = new BoundingBox(p.getLatitude(), p.getLongitude(), p.getLatitude(), p.getLongitude()); - for (int i=1; i Date: Mon, 13 Apr 2026 12:14:17 -0500 Subject: [PATCH 06/23] =?UTF-8?q?fix(map):=20address=20code=20review=20fin?= =?UTF-8?q?dings=20=E2=80=94=20precision,=20naming,=20icons,=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Int.toFloat() precision loss in track point filter by storing time as string in GeoJSON and using string-based equality comparison - Rename MapStyle enum values to match actual tile styles: Satellite→Light (Positron), Hybrid→RoadMap (Americana), with updated string resources - Reset bearingUpdate to IGNORE when gesture cancels location tracking - Use LocationOn icon for ALWAYS_NORTH tracking mode instead of misleading LocationDisabled - Remove dead isOfflineManagerAvailable() expect/actual declarations - Replace hardcoded English strings in offline map UI with stringResource() calls backed by core:resources entries --- .../composeResources/values/strings.xml | 10 ++++-- .../feature/map/OfflineManagerFactory.kt | 31 +++++++++++++------ .../org/meshtastic/feature/map/MapScreen.kt | 1 + .../feature/map/OfflineManagerFactory.kt | 8 ----- .../map/component/MapControlsOverlay.kt | 6 ++-- .../feature/map/component/NodeTrackLayers.kt | 7 +++-- .../meshtastic/feature/map/model/MapStyle.kt | 10 +++--- .../feature/map/util/GeoJsonConverters.kt | 2 +- .../feature/map/OfflineManagerFactory.kt | 31 +++++++++++++------ .../feature/map/OfflineManagerFactory.kt | 2 -- 10 files changed, 64 insertions(+), 44 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 5b1a8bc3ff..b1ccd95560 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1260,10 +1260,16 @@ Wirelessly manage your device settings and channels. Map style selection OpenStreetMap - Satellite + Light Terrain - Hybrid + Road Map Dark + Offline Maps + Download + Download visible region + Saves tiles for offline use + Downloaded Regions + Unnamed Region Battery: %1$d% Nodes: %1$d online / %2$d total diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt index 9b23f70038..561b056817 100644 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt @@ -38,15 +38,22 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource import org.maplibre.compose.camera.CameraState import org.maplibre.compose.material3.OfflinePackListItem import org.maplibre.compose.offline.OfflinePackDefinition import org.maplibre.compose.offline.rememberOfflineManager +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.done +import org.meshtastic.core.resources.offline_download +import org.meshtastic.core.resources.offline_download_visible_region +import org.meshtastic.core.resources.offline_downloaded_regions +import org.meshtastic.core.resources.offline_maps +import org.meshtastic.core.resources.offline_saves_tiles +import org.meshtastic.core.resources.offline_unnamed_region import org.meshtastic.core.ui.icon.CloudDownload import org.meshtastic.core.ui.icon.MeshtasticIcons -@Composable actual fun isOfflineManagerAvailable(): Boolean = true - @Suppress("LongMethod") @Composable actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { @@ -55,9 +62,10 @@ actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { var showDialog by remember { mutableStateOf(false) } if (showDialog) { + val unnamedRegion = stringResource(Res.string.offline_unnamed_region) AlertDialog( onDismissRequest = { showDialog = false }, - title = { Text("Offline Maps") }, + title = { Text(stringResource(Res.string.offline_maps)) }, text = { Column(modifier = Modifier.fillMaxWidth()) { // Download button for current viewport @@ -85,13 +93,16 @@ actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { ) { Icon( imageVector = MeshtasticIcons.CloudDownload, - contentDescription = "Download", + contentDescription = stringResource(Res.string.offline_download), modifier = Modifier.padding(end = 16.dp), ) Column { - Text(text = "Download visible region", style = MaterialTheme.typography.bodyLarge) Text( - text = "Saves tiles for offline use", + text = stringResource(Res.string.offline_download_visible_region), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(Res.string.offline_saves_tiles), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -101,27 +112,27 @@ actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { // Existing packs if (offlineManager.packs.isNotEmpty()) { Text( - text = "Downloaded Regions", + text = stringResource(Res.string.offline_downloaded_regions), style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), ) offlineManager.packs.toList().forEach { pack -> key(pack.hashCode()) { OfflinePackListItem(pack = pack, offlineManager = offlineManager) { - Text(pack.metadata?.decodeToString().orEmpty().ifBlank { "Unnamed Region" }) + Text(pack.metadata?.decodeToString().orEmpty().ifBlank { unnamedRegion }) } } } } } }, - confirmButton = { TextButton(onClick = { showDialog = false }) { Text("Done") } }, + confirmButton = { TextButton(onClick = { showDialog = false }) { Text(stringResource(Res.string.done)) } }, ) } // Expose the toggle via a side effect — the parent screen will call this // by rendering OfflineMapContent and using the showDialog state IconButton(onClick = { showDialog = true }) { - Icon(imageVector = MeshtasticIcons.CloudDownload, contentDescription = "Offline Maps") + Icon(imageVector = MeshtasticIcons.CloudDownload, contentDescription = stringResource(Res.string.offline_maps)) } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index f322b2ccbf..4fa57f01dc 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -199,6 +199,7 @@ fun MapScreen( LaunchedEffect(cameraState.moveReason) { if (cameraState.moveReason == CameraMoveReason.GESTURE && isLocationTrackingEnabled) { isLocationTrackingEnabled = false + bearingUpdate = BearingUpdate.IGNORE } } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt index ee130055ff..734afcec1d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt @@ -18,14 +18,6 @@ package org.meshtastic.feature.map import androidx.compose.runtime.Composable -/** - * Returns `true` if the platform supports offline map tile management. - * - Android: `true` (backed by MapLibre Native). - * - iOS: `true` (backed by MapLibre Native). - * - Desktop/JS: `false` (no offline support). - */ -@Composable expect fun isOfflineManagerAvailable(): Boolean - /** * Renders platform-specific offline map management UI if the platform supports it. The composable receives the current * style URI and [cameraState] for downloading the visible region. diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index e49fcbd132..e2e7ea3f9a 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -36,7 +36,7 @@ import org.meshtastic.core.resources.map_filter 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.LocationDisabled +import org.meshtastic.core.ui.icon.LocationOn import org.meshtastic.core.ui.icon.MapCompass import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MyLocation @@ -118,13 +118,13 @@ fun MapControlsOverlay( } } - // Location tracking button — 3 states: Off (MyLocation), Tracking (LocationDisabled), TrackingBearing (NearMe) + // Location tracking button — 3 states: Off (MyLocation), Tracking (NearMe), TrackingNorth (LocationOn) MapButton( icon = when { !isLocationTrackingEnabled -> MeshtasticIcons.MyLocation isTrackingBearing -> MeshtasticIcons.NearMe - else -> MeshtasticIcons.LocationDisabled + else -> MeshtasticIcons.LocationOn }, contentDescription = stringResource(Res.string.toggle_my_position), iconTint = if (isLocationTrackingEnabled) MaterialTheme.colorScheme.primary else null, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt index a4c8fdd5df..8fdcf0e33f 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt @@ -20,7 +20,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import org.maplibre.compose.expressions.dsl.asNumber +import kotlinx.serialization.json.jsonPrimitive +import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.eq import org.maplibre.compose.expressions.dsl.feature @@ -81,7 +82,7 @@ fun NodeTrackLayers( strokeWidth = const(1.dp), strokeColor = const(Color.White), onClick = { features -> - val time = features.firstOrNull()?.properties?.get("time")?.toString()?.toIntOrNull() + val time = features.firstOrNull()?.properties?.get("time")?.jsonPrimitive?.content?.toIntOrNull() if (time != null && onPositionSelected != null) { onPositionSelected(time) ClickResult.Consume @@ -96,7 +97,7 @@ fun NodeTrackLayers( CircleLayer( id = "node-track-selected", source = pointsSource, - filter = feature["time"].asNumber() eq const(selectedPositionTime.toFloat()), + filter = feature["time"].asString() eq const(selectedPositionTime.toString()), radius = const(10.dp), color = const(SelectedPointColor), // Red strokeWidth = const(2.dp), diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt index 334521d68d..339b69e507 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt @@ -20,9 +20,9 @@ import org.jetbrains.compose.resources.StringResource import org.maplibre.compose.style.BaseStyle import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map_style_dark -import org.meshtastic.core.resources.map_style_hybrid +import org.meshtastic.core.resources.map_style_light import org.meshtastic.core.resources.map_style_osm -import org.meshtastic.core.resources.map_style_satellite +import org.meshtastic.core.resources.map_style_road_map import org.meshtastic.core.resources.map_style_terrain /** @@ -35,15 +35,15 @@ enum class MapStyle(val label: StringResource, val styleUri: String) { OpenStreetMap(label = Res.string.map_style_osm, styleUri = "https://tiles.openfreemap.org/styles/liberty"), /** Clean, light cartographic style via OpenFreeMap Positron. */ - Satellite(label = Res.string.map_style_satellite, styleUri = "https://tiles.openfreemap.org/styles/positron"), + Light(label = Res.string.map_style_light, styleUri = "https://tiles.openfreemap.org/styles/positron"), /** Topographic style via OpenFreeMap Bright. */ Terrain(label = Res.string.map_style_terrain, styleUri = "https://tiles.openfreemap.org/styles/bright"), /** US road-map style via Americana. */ - Hybrid(label = Res.string.map_style_hybrid, styleUri = "https://americanamap.org/style.json"), + RoadMap(label = Res.string.map_style_road_map, styleUri = "https://americanamap.org/style.json"), - /** Dark mode style via OpenFreeMap Bright (dark palette). */ + /** Dark mode style via OpenFreeMap Fiord. */ Dark(label = Res.string.map_style_dark, styleUri = "https://tiles.openfreemap.org/styles/fiord"), ; diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index 622703f057..e111ad10a1 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -123,7 +123,7 @@ fun positionsToPointFeatures(positions: List): Fe if (lat == 0.0 && lng == 0.0) return@mapNotNull null val props = buildJsonObject { - put("time", pos.time ?: 0) + put("time", (pos.time ?: 0).toString()) put("altitude", pos.altitude ?: 0) put("ground_speed", pos.ground_speed ?: 0) put("sats_in_view", pos.sats_in_view ?: 0) diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt index 819cf17084..1f4acf1c47 100644 --- a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt +++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt @@ -38,15 +38,22 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource import org.maplibre.compose.camera.CameraState import org.maplibre.compose.material3.OfflinePackListItem import org.maplibre.compose.offline.OfflinePackDefinition import org.maplibre.compose.offline.rememberOfflineManager +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.done +import org.meshtastic.core.resources.offline_download +import org.meshtastic.core.resources.offline_download_visible_region +import org.meshtastic.core.resources.offline_downloaded_regions +import org.meshtastic.core.resources.offline_maps +import org.meshtastic.core.resources.offline_saves_tiles +import org.meshtastic.core.resources.offline_unnamed_region import org.meshtastic.core.ui.icon.CloudDownload import org.meshtastic.core.ui.icon.MeshtasticIcons -@Composable actual fun isOfflineManagerAvailable(): Boolean = true - @Suppress("LongMethod") @Composable actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { @@ -55,9 +62,10 @@ actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { var showDialog by remember { mutableStateOf(false) } if (showDialog) { + val unnamedRegion = stringResource(Res.string.offline_unnamed_region) AlertDialog( onDismissRequest = { showDialog = false }, - title = { Text("Offline Maps") }, + title = { Text(stringResource(Res.string.offline_maps)) }, text = { Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -84,13 +92,16 @@ actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { ) { Icon( imageVector = MeshtasticIcons.CloudDownload, - contentDescription = "Download", + contentDescription = stringResource(Res.string.offline_download), modifier = Modifier.padding(end = 16.dp), ) Column { - Text(text = "Download visible region", style = MaterialTheme.typography.bodyLarge) Text( - text = "Saves tiles for offline use", + text = stringResource(Res.string.offline_download_visible_region), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(Res.string.offline_saves_tiles), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -99,25 +110,25 @@ actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { if (offlineManager.packs.isNotEmpty()) { Text( - text = "Downloaded Regions", + text = stringResource(Res.string.offline_downloaded_regions), style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), ) offlineManager.packs.toList().forEach { pack -> key(pack.hashCode()) { OfflinePackListItem(pack = pack, offlineManager = offlineManager) { - Text(pack.metadata?.decodeToString().orEmpty().ifBlank { "Unnamed Region" }) + Text(pack.metadata?.decodeToString().orEmpty().ifBlank { unnamedRegion }) } } } } } }, - confirmButton = { TextButton(onClick = { showDialog = false }) { Text("Done") } }, + confirmButton = { TextButton(onClick = { showDialog = false }) { Text(stringResource(Res.string.done)) } }, ) } IconButton(onClick = { showDialog = true }) { - Icon(imageVector = MeshtasticIcons.CloudDownload, contentDescription = "Offline Maps") + Icon(imageVector = MeshtasticIcons.CloudDownload, contentDescription = stringResource(Res.string.offline_maps)) } } diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt index bda1f9aa12..e80a5eed6d 100644 --- a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt +++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt @@ -19,8 +19,6 @@ package org.meshtastic.feature.map import androidx.compose.runtime.Composable import org.maplibre.compose.camera.CameraState -@Composable actual fun isOfflineManagerAvailable(): Boolean = false - @Composable actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { // Offline map management is not available on Desktop. From 9cdac11ec0046073ed684182cdbb6d3b37b7e487 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 12:28:11 -0500 Subject: [PATCH 07/23] =?UTF-8?q?fix(map):=20address=20review=20round=202?= =?UTF-8?q?=20=E2=80=94=20precision=20circles,=20traceroute,=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix precision circle radius: use zoom-based exponential interpolation to convert meters to pixels instead of treating meters as dp values - Fix InlineMap precision circle: compute pixel radius from meters at the fixed zoom-15 display level - Fix TracerouteLayers: wrap callback in LaunchedEffect to avoid state updates during composition; add nodes to remember keys for fresh hop labels; use relatedNodeNums.size for accurate total count - Fix compass bearing: use epsilon comparison (±0.5°) instead of exact float equality to prevent flickering near north - Localize EditWaypointDialog: replace hardcoded English strings with stringResource() using existing waypoint_edit/waypoint_new resources - Format coordinates to 6 decimal places in waypoint position display --- .../map/component/EditWaypointDialog.kt | 19 ++++++++++-- .../feature/map/component/InlineMap.kt | 8 +++-- .../map/component/MapControlsOverlay.kt | 5 +++- .../map/component/MaplibreMapContent.kt | 29 +++++++++++++++++-- .../feature/map/component/TracerouteLayers.kt | 9 +++--- 5 files changed, 58 insertions(+), 12 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index 1392ab1aaa..bcbc836244 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -46,12 +46,16 @@ import org.meshtastic.core.resources.delete import org.meshtastic.core.resources.description import org.meshtastic.core.resources.name import org.meshtastic.core.resources.send +import org.meshtastic.core.resources.waypoint_edit +import org.meshtastic.core.resources.waypoint_lock_to_my_node +import org.meshtastic.core.resources.waypoint_new import org.meshtastic.feature.map.util.convertIntToEmoji import org.maplibre.spatialk.geojson.Position as GeoPosition private const val MAX_NAME_LENGTH = 29 private const val MAX_DESCRIPTION_LENGTH = 99 private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin +private const val COORDINATE_PRECISION = 1_000_000L /** * Dialog for creating or editing a waypoint on the map. @@ -81,7 +85,7 @@ fun EditWaypointDialog( onDismissRequest = onDismiss, title = { Text( - text = if (isEditing) "Edit Waypoint" else "New Waypoint", + text = stringResource(if (isEditing) Res.string.waypoint_edit else Res.string.waypoint_new), style = MaterialTheme.typography.headlineSmall, ) }, @@ -122,7 +126,10 @@ fun EditWaypointDialog( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(), ) { - Text("Lock to my node", style = MaterialTheme.typography.bodyMedium) + Text( + stringResource(Res.string.waypoint_lock_to_my_node), + style = MaterialTheme.typography.bodyMedium, + ) Switch(checked = locked, onCheckedChange = { locked = it }) } @@ -130,7 +137,7 @@ fun EditWaypointDialog( if (position != null) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "${position.latitude}, ${position.longitude}", + text = "${position.latitude.formatCoord()}, ${position.longitude.formatCoord()}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -166,3 +173,9 @@ fun EditWaypointDialog( }, ) } + +/** Format a coordinate to 6 decimal places without using JVM-only String.format(). */ +private fun Double.formatCoord(): String { + val rounded = (this * COORDINATE_PRECISION).toLong() / COORDINATE_PRECISION.toDouble() + return rounded.toString() +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 7e9cfa2cd0..70d7f4874d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -45,6 +45,9 @@ private const val COORDINATE_SCALE = 1e-7 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f +/** Ground resolution at zoom 15 (equatorial): ~4.773 meters per pixel. */ +private const val METERS_PER_PIXEL_ZOOM15 = 4.773 + /** * A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the * Google Maps and OSMDroid inline map implementations. @@ -89,13 +92,14 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { strokeColor = const(Color.White), ) - // Precision circle + // Precision circle — radius computed from precision_meters at zoom 15 val precisionMeters = precisionBitsToMeters(position.precision_bits ?: 0) if (precisionMeters > 0) { + val radiusDp = (precisionMeters / METERS_PER_PIXEL_ZOOM15).dp CircleLayer( id = "inline-node-precision", source = source, - radius = const(40.dp), // visual approximation + radius = const(radiusDp), color = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), strokeWidth = const(1.dp), strokeColor = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)), diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index e2e7ea3f9a..1da6d1f26a 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -44,6 +44,7 @@ import org.meshtastic.core.ui.icon.NearMe import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.icon.Tune import org.meshtastic.core.ui.theme.StatusColors.StatusRed +import kotlin.math.abs /** * Shared map controls overlay using [HorizontalFloatingToolbar] for Material 3 Expressive styling. Provides compass, @@ -133,12 +134,14 @@ fun MapControlsOverlay( } } +private const val BEARING_NORTH_THRESHOLD = 0.5f + @Composable private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) { val iconTint = when { isFollowing -> MaterialTheme.colorScheme.primary - bearing == 0f -> MaterialTheme.colorScheme.StatusRed + abs(bearing) < BEARING_NORTH_THRESHOLD -> MaterialTheme.colorScheme.StatusRed else -> null } MapButton( diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index dae42ed61f..346d5bf9d8 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -32,9 +32,13 @@ import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.convertToColor import org.maplibre.compose.expressions.dsl.convertToNumber import org.maplibre.compose.expressions.dsl.dp +import org.maplibre.compose.expressions.dsl.exponential import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.interpolate import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.dsl.offset +import org.maplibre.compose.expressions.dsl.times +import org.maplibre.compose.expressions.dsl.zoom import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.HillshadeLayer import org.maplibre.compose.layers.SymbolLayer @@ -63,6 +67,19 @@ private const val CLUSTER_RADIUS = 50 private const val CLUSTER_MIN_POINTS = 10 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f + +/** + * Ground resolution at the equator: meters per pixel = 156543.03 / 2^zoom. We use an exponential(2) interpolation with + * two stops to compute the conversion factor from meters to pixels at each zoom level. The result is multiplied by the + * per-feature `precision_meters` property to produce a screen-pixel radius. + */ +private const val EQUATORIAL_METERS_PER_PIXEL_ZOOM0 = 156543.03f +private const val PRECISION_ZOOM_MIN = 0 +private const val PRECISION_ZOOM_MAX = 24 +private const val PRECISION_SCALE_MIN = 1f / EQUATORIAL_METERS_PER_PIXEL_ZOOM0 + +@Suppress("MagicNumber") +private const val PRECISION_SCALE_MAX = 16_777_216f / EQUATORIAL_METERS_PER_PIXEL_ZOOM0 // 2^24 private const val CLUSTER_OPACITY = 0.85f private const val LABEL_OFFSET_EM = 1.5f private const val CLUSTER_ZOOM_INCREMENT = 2.0 @@ -232,13 +249,21 @@ private fun NodeMarkerLayers( iconAllowOverlap = const(true), ) - // Precision circles — sized by precision_meters property + // Precision circles — sized by precision_meters property converted to screen pixels via zoom interpolation if (showPrecisionCircle) { + // Meters-to-pixels factor doubles with each zoom level (equatorial approximation) + val metersToPixels = + interpolate( + exponential(2f), + zoom(), + PRECISION_ZOOM_MIN to const(PRECISION_SCALE_MIN), + PRECISION_ZOOM_MAX to const(PRECISION_SCALE_MAX), + ) CircleLayer( id = "node-precision", source = nodesSource, filter = !feature.has("cluster"), - radius = feature["precision_meters"].convertToNumber(const(0f)).dp, + radius = (feature["precision_meters"].convertToNumber(const(0f)) * metersToPixels).dp, color = feature["background_color"].convertToColor( const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index d87c216020..a386cc7c6f 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.map.component import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -62,12 +63,12 @@ fun TracerouteLayers( if (overlay == null) return // Build route line features - val routeData = remember(overlay, nodePositions) { buildTracerouteGeoJson(overlay, nodePositions, nodes) } + val routeData = remember(overlay, nodePositions, nodes) { buildTracerouteGeoJson(overlay, nodePositions, nodes) } - // Report mappable count + // Report mappable count via side effect (avoid state updates during composition) val mappableCount = routeData.hopFeatures.features.size - val totalCount = overlay.forwardRoute.size + overlay.returnRoute.size - onMappableCountChanged(mappableCount, totalCount) + val totalCount = overlay.relatedNodeNums.size + LaunchedEffect(mappableCount, totalCount) { onMappableCountChanged(mappableCount, totalCount) } // Forward route line if (routeData.forwardLine.features.isNotEmpty()) { From bce998d060eb3d766320ffe5e4537418fb76bc37 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 12:30:44 -0500 Subject: [PATCH 08/23] fix(map): fix formatCoord to produce fixed decimal places instead of scientific notation --- .../feature/map/component/EditWaypointDialog.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index bcbc836244..ccea107adf 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -50,6 +50,7 @@ import org.meshtastic.core.resources.waypoint_edit import org.meshtastic.core.resources.waypoint_lock_to_my_node import org.meshtastic.core.resources.waypoint_new import org.meshtastic.feature.map.util.convertIntToEmoji +import kotlin.math.abs import org.maplibre.spatialk.geojson.Position as GeoPosition private const val MAX_NAME_LENGTH = 29 @@ -175,7 +176,12 @@ fun EditWaypointDialog( } /** Format a coordinate to 6 decimal places without using JVM-only String.format(). */ +@Suppress("MagicNumber") private fun Double.formatCoord(): String { - val rounded = (this * COORDINATE_PRECISION).toLong() / COORDINATE_PRECISION.toDouble() - return rounded.toString() + val negative = this < 0 + val absVal = abs(this) + val wholePart = absVal.toLong() + val fracPart = ((absVal - wholePart) * COORDINATE_PRECISION + 0.5).toLong() + val fracStr = fracPart.toString().padStart(6, '0') + return "${if (negative) "-" else ""}$wholePart.$fracStr" } From ec6ebce04d10e0d314634fb4ce6ae55f44ae2f13 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 13:04:02 -0500 Subject: [PATCH 09/23] =?UTF-8?q?refactor(map):=20architectural=20improvem?= =?UTF-8?q?ents=20=E2=80=94=20DRY,=20UDF,=20dead=20code,=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract COORDINATE_SCALE to shared MapConstants.kt, removing 6 duplicate private const declarations across MapScreen, GeoJsonConverters, InlineMap, NodeTrackMap, TracerouteLayers, and TracerouteMap - Move node filtering from MapScreen composition into BaseMapViewModel as filteredNodes StateFlow (testable, avoids composition-time computation) - Move waypoint construction from MapScreen's inline onSend callback into MapViewModel.createAndSendWaypoint() for testability and separation - Remove unused compassBearing property from MapViewModel (bearing is read directly from cameraState.position.bearing in MapScreen) - Add nodes parameter to TracerouteMap for short name resolution on hop markers (was hardcoded to emptyMap, falling back to hex node nums) - Add GeoJsonConvertersTest with 25 tests covering nodesToFeatureCollection, waypointsToFeatureCollection, positionsToLineString, positionsToPointFeatures, precisionBitsToMeters, intToHexColor, and convertIntToEmoji - Expand BaseMapViewModelTest from 5 to 21 tests covering filter toggles, preference persistence, mapFilterState composition, filteredNodes with favorites/last-heard/any filters, and getNodeOrFallback - Expand MapViewModelTest from 9 to 12 tests covering createAndSendWaypoint with new/edit/locked/no-position scenarios --- .../feature/map/BaseMapViewModel.kt | 14 + .../org/meshtastic/feature/map/MapScreen.kt | 52 +--- .../meshtastic/feature/map/MapViewModel.kt | 35 ++- .../feature/map/component/InlineMap.kt | 2 +- .../feature/map/component/NodeTrackMap.kt | 2 +- .../feature/map/component/TracerouteLayers.kt | 2 +- .../feature/map/component/TracerouteMap.kt | 9 +- .../feature/map/util/GeoJsonConverters.kt | 3 - .../feature/map/util/MapConstants.kt | 20 ++ .../feature/map/BaseMapViewModelTest.kt | 226 +++++++++----- .../feature/map/MapViewModelTest.kt | 120 +++++++- .../feature/map/util/GeoJsonConvertersTest.kt | 275 ++++++++++++++++++ 12 files changed, 631 insertions(+), 129 deletions(-) create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt 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 fdfd3f05a0..d837e9f09e 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 @@ -192,6 +192,20 @@ open class BaseMapViewModel( lastHeardTrackFilter.value, ), ) + + /** Nodes with position, filtered by favorites and last-heard preferences. */ + val filteredNodes: StateFlow> = + combine(nodesWithPosition, mapFilterStateFlow) { nodes, filter -> + val myNum = myNodeNum + nodes + .filter { node -> !filter.onlyFavorites || node.isFavorite || node.num == myNum } + .filter { node -> + filter.lastHeardFilter.seconds == 0L || + (nowSeconds - node.lastHeard) <= filter.lastHeardFilter.seconds || + node.num == myNum + } + } + .stateInWhileSubscribed(initialValue = emptyList()) } /** diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 4fa57f01dc..80002f3850 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -40,7 +40,6 @@ import org.maplibre.compose.location.LocationTrackingEffect import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberUserLocationState import org.maplibre.compose.map.GestureOptions -import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map import org.meshtastic.core.ui.component.MainAppBar @@ -50,11 +49,9 @@ import org.meshtastic.feature.map.component.MapFilterDropdown import org.meshtastic.feature.map.component.MapStyleSelector import org.meshtastic.feature.map.component.MaplibreMapContent import org.meshtastic.feature.map.model.MapStyle -import org.meshtastic.proto.Waypoint +import org.meshtastic.feature.map.util.COORDINATE_SCALE import org.maplibre.spatialk.geojson.Position as GeoPosition -/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */ -private const val COORDINATE_SCALE = 1e-7 private const val WAYPOINT_ZOOM = 15.0 /** @@ -75,7 +72,7 @@ fun MapScreen( ) { val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle() val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() - val nodesWithPosition by viewModel.nodesWithPosition.collectAsStateWithLifecycle() + val filteredNodes by viewModel.filteredNodes.collectAsStateWithLifecycle() val waypoints by viewModel.waypoints.collectAsStateWithLifecycle() val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle() val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle() @@ -130,19 +127,6 @@ fun MapScreen( } } - // Apply favorites and last-heard filters to the node list - val myNum = viewModel.myNodeNum - val filteredNodes = - remember(nodesWithPosition, filterState, myNum) { - nodesWithPosition - .filter { node -> !filterState.onlyFavorites || node.isFavorite || node.num == myNum } - .filter { node -> - filterState.lastHeardFilter.seconds == 0L || - (nowSeconds - node.lastHeard) <= filterState.lastHeardFilter.seconds || - node.num == myNum - } - } - @Suppress("ViewModelForwarding") Scaffold( modifier = modifier, @@ -264,29 +248,15 @@ fun MapScreen( longPressPosition = null }, onSend = { name, description, icon, locked, expire -> - val myNodeNum = viewModel.myNodeNum ?: 0 - val wpt = - Waypoint( - id = editingWaypoint?.id ?: viewModel.generatePacketId(), - name = name, - description = description, - icon = icon, - locked_to = if (locked) myNodeNum else 0, - latitude_i = - if (editingWaypoint != null) { - editingWaypoint.latitude_i - } else { - longPressPosition?.let { (it.latitude / COORDINATE_SCALE).toInt() } ?: 0 - }, - longitude_i = - if (editingWaypoint != null) { - editingWaypoint.longitude_i - } else { - longPressPosition?.let { (it.longitude / COORDINATE_SCALE).toInt() } ?: 0 - }, - expire = expire, - ) - viewModel.sendWaypoint(wpt) + viewModel.createAndSendWaypoint( + name = name, + description = description, + icon = icon, + locked = locked, + expire = expire, + existingWaypoint = editingWaypoint, + position = longPressPosition, + ) }, onDelete = editingWaypoint?.let { wpt -> { viewModel.deleteWaypoint(wpt.id) } }, initialName = editingWaypoint?.name ?: "", diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt index f055021274..60e5df0cd4 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -31,6 +31,8 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.proto.Waypoint import org.maplibre.spatialk.geojson.Position as GeoPosition /** @@ -94,7 +96,34 @@ class MapViewModel( mapCameraPrefs.setSelectedStyleUri(style.styleUri) } - /** Bearing for the compass in degrees. */ - val compassBearing: Float - get() = mapCameraPrefs.cameraBearing.value + /** + * Create a [Waypoint] proto from user-provided fields, handling coordinate conversion and ID generation. + * + * @param existingWaypoint If non-null, the waypoint being edited (retains its id and coordinates). + * @param position If non-null, the long-press position for a new waypoint. + */ + fun createAndSendWaypoint( + name: String, + description: String, + icon: Int, + locked: Boolean, + expire: Int, + existingWaypoint: Waypoint?, + position: GeoPosition?, + ) { + val wpt = + Waypoint( + id = existingWaypoint?.id ?: generatePacketId(), + name = name, + description = description, + icon = icon, + locked_to = if (locked) (myNodeNum ?: 0) else 0, + latitude_i = + existingWaypoint?.latitude_i ?: position?.let { (it.latitude / COORDINATE_SCALE).toInt() } ?: 0, + longitude_i = + existingWaypoint?.longitude_i ?: position?.let { (it.longitude / COORDINATE_SCALE).toInt() } ?: 0, + expire = expire, + ) + sendWaypoint(wpt) + } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 70d7f4874d..b13af0cd3c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -37,11 +37,11 @@ import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node +import org.meshtastic.feature.map.util.COORDINATE_SCALE import org.meshtastic.feature.map.util.precisionBitsToMeters import org.maplibre.spatialk.geojson.Position as GeoPosition private const val DEFAULT_ZOOM = 15.0 -private const val COORDINATE_SCALE = 1e-7 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index c919b2afa1..5ea9b5ef6f 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -30,11 +30,11 @@ import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.feature.map.util.COORDINATE_SCALE import org.meshtastic.proto.Position import org.maplibre.spatialk.geojson.Position as GeoPosition private const val DEFAULT_TRACK_ZOOM = 13.0 -private const val COORDINATE_SCALE = 1e-7 private const val BOUNDS_PADDING_DP = 48 /** diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index a386cc7c6f..45369590e9 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -40,12 +40,12 @@ import org.maplibre.spatialk.geojson.LineString import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.feature.map.util.COORDINATE_SCALE import org.maplibre.spatialk.geojson.Position as GeoPosition private val ForwardRouteColor = Color(0xFF4CAF50) private val ReturnRouteColor = Color(0xFFF44336) private val HopMarkerColor = Color(0xFF9C27B0) -private const val COORDINATE_SCALE = 1e-7 private const val HEX_RADIX = 16 private const val ROUTE_OPACITY = 0.8f diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index 7dbb9b0296..4f1a69309d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -29,13 +29,14 @@ import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions import org.maplibre.spatialk.geojson.BoundingBox +import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.feature.map.util.COORDINATE_SCALE import org.meshtastic.proto.Position import org.maplibre.spatialk.geojson.Position as GeoPosition private const val DEFAULT_TRACEROUTE_ZOOM = 10.0 -private const val COORDINATE_SCALE = 1e-7 private const val BOUNDS_PADDING_DP = 64 /** @@ -44,6 +45,9 @@ private const val BOUNDS_PADDING_DP = 64 * This composable is designed to be embedded inside a parent scaffold (e.g. TracerouteMapScreen). It does NOT include * its own Scaffold or AppBar. * + * @param nodes Node lookup map for resolving short names on hop markers. When empty, hop markers fall back to hex node + * numbers. Callers should pass `nodeRepository.nodeDBbyNum.value` (or equivalent) for readable labels. + * * Replaces both the Google Maps and OSMDroid flavor-specific TracerouteMap implementations. */ @Composable @@ -52,6 +56,7 @@ fun TracerouteMap( tracerouteNodePositions: Map, onMappableCountChanged: (shown: Int, total: Int) -> Unit, modifier: Modifier = Modifier, + nodes: Map = emptyMap(), ) { val geoPositions = remember(tracerouteNodePositions) { @@ -99,7 +104,7 @@ fun TracerouteMap( TracerouteLayers( overlay = tracerouteOverlay, nodePositions = tracerouteNodePositions, - nodes = emptyMap(), // Node lookups for short names are best-effort + nodes = nodes, onMappableCountChanged = onMappableCountChanged, ) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index e111ad10a1..cbff63e81b 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -27,9 +27,6 @@ import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.maplibre.spatialk.geojson.Position as GeoPosition -/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */ -private const val COORDINATE_SCALE = 1e-7 - private const val MIN_PRECISION_BITS = 10 private const val MAX_PRECISION_BITS = 19 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt new file mode 100644 index 0000000000..cdecf8ecdc --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt @@ -0,0 +1,20 @@ +/* + * 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.util + +/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */ +const val COORDINATE_SCALE = 1e-7 diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index 336de2a44d..fea1de0e00 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -29,28 +29,30 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.repository.MapPrefs +import org.meshtastic.core.model.Node import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.testing.FakeMapPrefs import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.core.testing.TestDataFactory -import org.meshtastic.proto.Waypoint +import org.meshtastic.proto.Position import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("MagicNumber") class BaseMapViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() private lateinit var viewModel: BaseMapViewModel private lateinit var nodeRepository: FakeNodeRepository private lateinit var radioController: FakeRadioController - private lateinit var waypointPacketsFlow: MutableStateFlow> - private val mapPrefs: MapPrefs = mock() + private lateinit var mapPrefs: FakeMapPrefs private val packetRepository: PacketRepository = mock() @BeforeTest @@ -59,23 +61,9 @@ class BaseMapViewModelTest { nodeRepository = FakeNodeRepository() radioController = FakeRadioController() radioController.setConnectionState(ConnectionState.Disconnected) - - every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false) - every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(false) - every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(false) - every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L) - every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L) - - waypointPacketsFlow = MutableStateFlow(emptyList()) - every { packetRepository.getWaypoints() } returns waypointPacketsFlow - - viewModel = - BaseMapViewModel( - mapPrefs = mapPrefs, - nodeRepository = nodeRepository, - packetRepository = packetRepository, - radioController = radioController, - ) + mapPrefs = FakeMapPrefs() + every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList()) + viewModel = createViewModel() } @AfterTest @@ -83,6 +71,28 @@ class BaseMapViewModelTest { Dispatchers.resetMain() } + private fun createViewModel(): BaseMapViewModel = BaseMapViewModel( + mapPrefs = mapPrefs, + nodeRepository = nodeRepository, + packetRepository = packetRepository, + radioController = radioController, + ) + + private fun nodeWithPosition( + num: Int, + latI: Int = 400000000, + lngI: Int = -740000000, + isFavorite: Boolean = false, + lastHeard: Int = nowSeconds.toInt(), + ): Node = Node( + num = num, + position = Position(latitude_i = latI, longitude_i = lngI), + isFavorite = isFavorite, + lastHeard = lastHeard, + ) + + // ---- Initialization ---- + @Test fun testInitialization() { assertNotNull(viewModel) @@ -107,12 +117,9 @@ class BaseMapViewModelTest { @Test fun testConnectionStateFlow() = runTest(testDispatcher) { viewModel.isConnected.test { - // Initially reflects radioController state (which is Disconnected in FakeRadioController default) assertEquals(false, awaitItem()) - radioController.setConnectionState(ConnectionState.Connected) assertEquals(true, awaitItem()) - radioController.setConnectionState(ConnectionState.Disconnected) assertEquals(false, awaitItem()) cancelAndIgnoreRemainingEvents() @@ -123,81 +130,166 @@ class BaseMapViewModelTest { fun testNodeRepositoryIntegration() = runTest(testDispatcher) { val testNodes = TestDataFactory.createTestNodes(3) nodeRepository.setNodes(testNodes) - assertEquals(3, nodeRepository.nodeDBbyNum.value.size) } + // ---- Filter toggle tests ---- + @Test - fun testWaypointsIncludeFutureExpirations() = runTest(testDispatcher) { - val now = nowSeconds.toInt() - val futureWaypoint = waypointPacket(id = 1, expire = now + 60) + fun toggleOnlyFavorites_togglesState() { + assertFalse(viewModel.showOnlyFavoritesOnMap.value) + viewModel.toggleOnlyFavorites() + assertTrue(viewModel.showOnlyFavoritesOnMap.value) + viewModel.toggleOnlyFavorites() + assertFalse(viewModel.showOnlyFavoritesOnMap.value) + } - viewModel.waypoints.test { - assertEquals(emptyMap(), awaitItem()) + @Test + fun toggleOnlyFavorites_persistsToPrefs() { + viewModel.toggleOnlyFavorites() + assertTrue(mapPrefs.showOnlyFavorites.value) + } - waypointPacketsFlow.value = listOf(futureWaypoint) + @Test + fun toggleShowWaypointsOnMap_togglesState() { + // FakeMapPrefs defaults to true + assertTrue(viewModel.showWaypointsOnMap.value) + viewModel.toggleShowWaypointsOnMap() + assertFalse(viewModel.showWaypointsOnMap.value) + } - assertEquals(mapOf(1 to futureWaypoint), awaitItem()) - cancelAndIgnoreRemainingEvents() - } + @Test + fun toggleShowPrecisionCircleOnMap_togglesState() { + assertTrue(viewModel.showPrecisionCircleOnMap.value) + viewModel.toggleShowPrecisionCircleOnMap() + assertFalse(viewModel.showPrecisionCircleOnMap.value) } @Test - fun testWaypointsExcludeBoundaryExpirations() = runTest(testDispatcher) { - val now = nowSeconds.toInt() - val expiredAtNowWaypoint = waypointPacket(id = 2, expire = now) + fun setLastHeardFilter_updatesStateAndPrefs() { + viewModel.setLastHeardFilter(LastHeardFilter.OneHour) + assertEquals(LastHeardFilter.OneHour, viewModel.lastHeardFilter.value) + assertEquals(3600L, mapPrefs.lastHeardFilter.value) + } - viewModel.waypoints.test { - assertEquals(emptyMap(), awaitItem()) + @Test + fun setLastHeardTrackFilter_updatesStateAndPrefs() { + viewModel.setLastHeardTrackFilter(LastHeardFilter.OneDay) + assertEquals(LastHeardFilter.OneDay, viewModel.lastHeardTrackFilter.value) + assertEquals(86400L, mapPrefs.lastHeardTrackFilter.value) + } - waypointPacketsFlow.value = listOf(expiredAtNowWaypoint) + // ---- MapFilterState composition ---- - expectNoEvents() - assertEquals(emptyMap(), viewModel.waypoints.value) + @Test + fun mapFilterState_reflectsAllFilterValues() = runTest(testDispatcher) { + viewModel.mapFilterStateFlow.test { + val initial = awaitItem() + assertFalse(initial.onlyFavorites) + assertTrue(initial.showWaypoints) + assertTrue(initial.showPrecisionCircle) + assertEquals(LastHeardFilter.Any, initial.lastHeardFilter) + + viewModel.toggleOnlyFavorites() + val updated = awaitItem() + assertTrue(updated.onlyFavorites) cancelAndIgnoreRemainingEvents() } } + // ---- filteredNodes tests ---- + @Test - fun testWaypointsIncludeNeverExpiringWaypoints() = runTest(testDispatcher) { - val neverExpiresWaypoint = waypointPacket(id = 3, expire = 0) + fun filteredNodes_noFilters_returnsAllNodesWithPosition() = runTest(testDispatcher) { + val nodes = listOf(nodeWithPosition(1), nodeWithPosition(2), nodeWithPosition(3)) + nodeRepository.setNodes(nodes) + + viewModel.filteredNodes.test { + val result = expectMostRecentItem() + assertEquals(3, result.size) + cancelAndIgnoreRemainingEvents() + } + } - viewModel.waypoints.test { - assertEquals(emptyMap(), awaitItem()) + @Test + fun filteredNodes_favoritesFilter_showsOnlyFavoritesAndMyNode() = runTest(testDispatcher) { + val myNodeNum = 1 + nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum)) + val nodes = + listOf( + nodeWithPosition(myNodeNum), + nodeWithPosition(2, isFavorite = true), + nodeWithPosition(3, isFavorite = false), + ) + nodeRepository.setNodes(nodes) - waypointPacketsFlow.value = listOf(neverExpiresWaypoint) + viewModel.toggleOnlyFavorites() - assertEquals(mapOf(3 to neverExpiresWaypoint), awaitItem()) + viewModel.filteredNodes.test { + val result = expectMostRecentItem() + val nodeNums = result.map { it.num }.toSet() + // My node (1) + favorite node (2) should be present; non-favorite (3) filtered out + assertTrue(myNodeNum in nodeNums, "My node should always be visible") + assertTrue(2 in nodeNums, "Favorite node should be visible") + assertFalse(3 in nodeNums, "Non-favorite node should be filtered out") cancelAndIgnoreRemainingEvents() } } @Test - fun testWaypointsFilterMixedExpiredAndActiveWaypoints() = runTest(testDispatcher) { + fun filteredNodes_lastHeardFilter_excludesStaleNodes() = runTest(testDispatcher) { val now = nowSeconds.toInt() - val expiredWaypoint = waypointPacket(id = 4, expire = now - 1) - val activeWaypoint = waypointPacket(id = 5, expire = now + 60) - val neverExpiresWaypoint = waypointPacket(id = 6, expire = 0) + val nodes = + listOf( + nodeWithPosition(1, lastHeard = now), // heard just now + nodeWithPosition(2, lastHeard = now - 7200), // heard 2 hours ago + ) + nodeRepository.setNodes(nodes) - viewModel.waypoints.test { - assertEquals(emptyMap(), awaitItem()) + viewModel.setLastHeardFilter(LastHeardFilter.OneHour) - waypointPacketsFlow.value = listOf(expiredWaypoint, activeWaypoint, neverExpiresWaypoint) + viewModel.filteredNodes.test { + val result = expectMostRecentItem() + val nodeNums = result.map { it.num }.toSet() + assertTrue(1 in nodeNums, "Recently heard node should be visible") + assertFalse(2 in nodeNums, "Stale node should be filtered out with 1-hour filter") + cancelAndIgnoreRemainingEvents() + } + } - assertEquals( - mapOf( - activeWaypoint.waypoint!!.id to activeWaypoint, - neverExpiresWaypoint.waypoint!!.id to neverExpiresWaypoint, - ), - awaitItem(), + @Test + fun filteredNodes_anyFilter_showsAllNodes() = runTest(testDispatcher) { + val now = nowSeconds.toInt() + val nodes = + listOf( + nodeWithPosition(1, lastHeard = now), + nodeWithPosition(2, lastHeard = now - 200000), // very old ) + nodeRepository.setNodes(nodes) + + viewModel.setLastHeardFilter(LastHeardFilter.Any) + + viewModel.filteredNodes.test { + val result = expectMostRecentItem() + assertEquals(2, result.size, "Any filter should show all nodes") cancelAndIgnoreRemainingEvents() } } - private fun waypointPacket(id: Int, expire: Int): DataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, - channel = 0, - waypoint = Waypoint(id = id, name = "Waypoint $id", expire = expire), - ) + // ---- getNodeOrFallback ---- + + @Test + fun getNodeOrFallback_existingNode_returnsNode() { + val testNode = TestDataFactory.createTestNode(num = 42, longName = "Found") + nodeRepository.setNodes(listOf(testNode)) + val result = viewModel.getNodeOrFallback(42) + assertEquals(42, result.num) + assertEquals("Found", result.user.long_name) + } + + @Test + fun getNodeOrFallback_missingNode_returnsFallback() { + val result = viewModel.getNodeOrFallback(9999) + assertEquals(9999, result.num) + } } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt index 7981ab1df3..a875d9e2a8 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt @@ -23,6 +23,7 @@ import dev.mokkery.every import dev.mokkery.mock import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -35,6 +36,7 @@ import org.meshtastic.core.testing.FakeMapPrefs import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.proto.Waypoint import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -48,6 +50,7 @@ class MapViewModelTest { private lateinit var viewModel: MapViewModel private lateinit var mapCameraPrefs: FakeMapCameraPrefs private lateinit var mapPrefs: FakeMapPrefs + private lateinit var radioController: FakeRadioController private val packetRepository: PacketRepository = mock() @BeforeTest @@ -55,6 +58,7 @@ class MapViewModelTest { Dispatchers.setMain(testDispatcher) mapCameraPrefs = FakeMapCameraPrefs() mapPrefs = FakeMapPrefs() + radioController = FakeRadioController() every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList()) viewModel = createViewModel() } @@ -64,12 +68,15 @@ class MapViewModelTest { Dispatchers.resetMain() } - private fun createViewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()): MapViewModel = MapViewModel( + private fun createViewModel( + savedStateHandle: SavedStateHandle = SavedStateHandle(), + nodeRepository: FakeNodeRepository = FakeNodeRepository(), + ): MapViewModel = MapViewModel( mapPrefs = mapPrefs, mapCameraPrefs = mapCameraPrefs, - nodeRepository = FakeNodeRepository(), + nodeRepository = nodeRepository, packetRepository = packetRepository, - radioController = FakeRadioController(), + radioController = radioController, savedStateHandle = savedStateHandle, ) @@ -165,13 +172,6 @@ class MapViewModelTest { } } - @Test - fun compassBearingReflectsPrefs() { - mapCameraPrefs.setCameraBearing(180f) - val vm = createViewModel() - assertEquals(180f, vm.compassBearing) - } - @Test fun blankStyleUriFallsBackToOpenStreetMap() = runTest(testDispatcher) { // selectedStyleUri defaults to "" in FakeMapCameraPrefs @@ -181,4 +181,104 @@ class MapViewModelTest { cancelAndIgnoreRemainingEvents() } } + + // ---- createAndSendWaypoint tests ---- + + @Test + fun createAndSendWaypoint_newWaypoint_convertsPositionToIntCoordinates() = runTest(testDispatcher) { + val position = org.maplibre.spatialk.geojson.Position(longitude = -74.0, latitude = 40.0) + + viewModel.createAndSendWaypoint( + name = "Test WP", + description = "A waypoint", + icon = 0x1F4CD, + locked = false, + expire = 0, + existingWaypoint = null, + position = position, + ) + + // sendWaypoint dispatches to ioDispatcher; give it time to execute + delay(100) + + // FakeRadioController.getPacketId() returns 1, and sendMessage appends to sentPackets + assertEquals(1, radioController.sentPackets.size) + val sent = radioController.sentPackets.first() + val wpt = sent.waypoint!! + assertEquals("Test WP", wpt.name) + assertEquals("A waypoint", wpt.description) + assertEquals(0x1F4CD, wpt.icon) + assertEquals(0, wpt.locked_to) + // 40.0 / 1e-7 = 400000000 + assertEquals(400000000, wpt.latitude_i) + // -74.0 / 1e-7 = -740000000 + assertEquals(-740000000, wpt.longitude_i) + } + + @Test + fun createAndSendWaypoint_editExisting_retainsOriginalCoordinates() = runTest(testDispatcher) { + val existing = Waypoint(id = 42, name = "Old Name", latitude_i = 515000000, longitude_i = -1000000) + + viewModel.createAndSendWaypoint( + name = "New Name", + description = "Updated", + icon = 0x1F3E0, + locked = false, + expire = 0, + existingWaypoint = existing, + position = null, + ) + + delay(100) + + assertEquals(1, radioController.sentPackets.size) + val wpt = radioController.sentPackets.first().waypoint!! + assertEquals(42, wpt.id) // Retains existing ID + assertEquals("New Name", wpt.name) + assertEquals(515000000, wpt.latitude_i) // Retains existing coords + assertEquals(-1000000, wpt.longitude_i) + } + + @Test + fun createAndSendWaypoint_locked_setsLockedToMyNodeNum() = runTest(testDispatcher) { + val nodeRepository = FakeNodeRepository() + nodeRepository.setMyNodeInfo(org.meshtastic.core.testing.TestDataFactory.createMyNodeInfo(myNodeNum = 99)) + val vm = createViewModel(nodeRepository = nodeRepository) + val position = org.maplibre.spatialk.geojson.Position(longitude = 0.1, latitude = 0.1) + + vm.createAndSendWaypoint( + name = "Locked WP", + description = "", + icon = 0, + locked = true, + expire = 0, + existingWaypoint = null, + position = position, + ) + + delay(100) + + assertEquals(1, radioController.sentPackets.size) + assertEquals(99, radioController.sentPackets.first().waypoint!!.locked_to) + } + + @Test + fun createAndSendWaypoint_noPositionNoExisting_usesZeroCoordinates() = runTest(testDispatcher) { + viewModel.createAndSendWaypoint( + name = "Nowhere", + description = "", + icon = 0, + locked = false, + expire = 0, + existingWaypoint = null, + position = null, + ) + + delay(100) + + assertEquals(1, radioController.sentPackets.size) + val wpt = radioController.sentPackets.first().waypoint!! + assertEquals(0, wpt.latitude_i) + assertEquals(0, wpt.longitude_i) + } } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt new file mode 100644 index 0000000000..5ce3452141 --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.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.map.util + +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.proto.Position +import org.meshtastic.proto.User +import org.meshtastic.proto.Waypoint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@Suppress("MagicNumber") +class GeoJsonConvertersTest { + + // --- nodesToFeatureCollection --- + + @Test + fun nodesToFeatureCollection_emptyList_returnsEmptyCollection() { + val result = nodesToFeatureCollection(emptyList()) + assertTrue(result.features.isEmpty()) + } + + @Test + fun nodesToFeatureCollection_skipsNodesWithoutPosition() { + val node = Node(num = 1, position = Position()) + val result = nodesToFeatureCollection(listOf(node)) + assertTrue(result.features.isEmpty()) + } + + @Test + fun nodesToFeatureCollection_skipsZeroLatLng() { + val node = Node(num = 1, position = Position(latitude_i = 0, longitude_i = 0)) + val result = nodesToFeatureCollection(listOf(node)) + assertTrue(result.features.isEmpty()) + } + + @Test + fun nodesToFeatureCollection_convertsValidNode() { + val node = + Node( + num = 42, + user = User(short_name = "AB", long_name = "Alpha Bravo"), + position = Position(latitude_i = 400000000, longitude_i = -740000000), + lastHeard = 1000, + isFavorite = true, + hopsAway = 2, + viaMqtt = false, + snr = 5.5f, + rssi = -80, + ) + val result = nodesToFeatureCollection(listOf(node), myNodeNum = 42) + assertEquals(1, result.features.size) + + val feature = result.features.first() + val coords = feature.geometry.coordinates + assertEquals(40.0, coords.latitude, 0.001) + assertEquals(-74.0, coords.longitude, 0.001) + + val props = feature.properties!! + assertEquals(42, props["node_num"]?.toString()?.toIntOrNull()) + assertEquals("\"AB\"", props["short_name"].toString()) + assertEquals("\"Alpha Bravo\"", props["long_name"].toString()) + assertEquals("true", props["is_favorite"].toString()) + assertEquals("true", props["is_my_node"].toString()) + } + + @Test + fun nodesToFeatureCollection_isMyNodeFalseForOtherNodes() { + val node = Node(num = 10, position = Position(latitude_i = 400000000, longitude_i = -740000000)) + val result = nodesToFeatureCollection(listOf(node), myNodeNum = 42) + val props = result.features.first().properties!! + assertEquals("false", props["is_my_node"].toString()) + } + + @Test + fun nodesToFeatureCollection_multipleNodes() { + val nodes = + listOf( + Node(num = 1, position = Position(latitude_i = 100000000, longitude_i = 200000000)), + Node(num = 2, position = Position(latitude_i = 300000000, longitude_i = 400000000)), + ) + val result = nodesToFeatureCollection(nodes) + assertEquals(2, result.features.size) + } + + // --- waypointsToFeatureCollection --- + + @Test + fun waypointsToFeatureCollection_emptyMap_returnsEmptyCollection() { + val result = waypointsToFeatureCollection(emptyMap()) + assertTrue(result.features.isEmpty()) + } + + @Test + fun waypointsToFeatureCollection_skipsZeroLatLng() { + val waypoint = Waypoint(id = 1, latitude_i = 0, longitude_i = 0, name = "Test") + val packet = DataPacket("dest", 0, waypoint) + val result = waypointsToFeatureCollection(mapOf(1 to packet)) + assertTrue(result.features.isEmpty()) + } + + @Test + fun waypointsToFeatureCollection_convertsValidWaypoint() { + val waypoint = + Waypoint( + id = 99, + name = "Home", + description = "My house", + icon = 0x1F3E0, // House emoji + locked_to = 42, + latitude_i = 515000000, + longitude_i = -1000000, + expire = 0, + ) + val packet = DataPacket("dest", 0, waypoint) + val result = waypointsToFeatureCollection(mapOf(99 to packet)) + + assertEquals(1, result.features.size) + val feature = result.features.first() + val coords = feature.geometry.coordinates + assertEquals(51.5, coords.latitude, 0.001) + assertEquals(-0.1, coords.longitude, 0.001) + + val props = feature.properties!! + assertEquals(99, props["waypoint_id"]?.toString()?.toIntOrNull()) + assertEquals("\"Home\"", props["name"].toString()) + } + + // --- positionsToLineString --- + + @Test + fun positionsToLineString_lessThanTwoPositions_returnsEmptyCollection() { + val result = positionsToLineString(listOf(Position(latitude_i = 100000000, longitude_i = 200000000))) + assertTrue(result.features.isEmpty()) + } + + @Test + fun positionsToLineString_emptyList_returnsEmptyCollection() { + val result = positionsToLineString(emptyList()) + assertTrue(result.features.isEmpty()) + } + + @Test + fun positionsToLineString_validPositions_createsLineString() { + val positions = + listOf( + Position(latitude_i = 100000000, longitude_i = 200000000), + Position(latitude_i = 110000000, longitude_i = 210000000), + Position(latitude_i = 120000000, longitude_i = 220000000), + ) + val result = positionsToLineString(positions) + assertEquals(1, result.features.size) + } + + @Test + fun positionsToLineString_skipsZeroCoords() { + val positions = + listOf( + Position(latitude_i = 100000000, longitude_i = 200000000), + Position(latitude_i = 0, longitude_i = 0), + Position(latitude_i = 120000000, longitude_i = 220000000), + ) + val result = positionsToLineString(positions) + assertEquals(1, result.features.size) + } + + // --- positionsToPointFeatures --- + + @Test + fun positionsToPointFeatures_emptyList_returnsEmptyCollection() { + val result = positionsToPointFeatures(emptyList()) + assertTrue(result.features.isEmpty()) + } + + @Test + fun positionsToPointFeatures_convertsValidPositions() { + val positions = listOf(Position(latitude_i = 400000000, longitude_i = -740000000, time = 1000, altitude = 100)) + val result = positionsToPointFeatures(positions) + assertEquals(1, result.features.size) + val props = result.features.first().properties!! + assertEquals("\"1000\"", props["time"].toString()) + assertEquals(100, props["altitude"]?.toString()?.toIntOrNull()) + } + + // --- precisionBitsToMeters --- + + @Test + fun precisionBitsToMeters_knownValues() { + assertEquals(5886.0, precisionBitsToMeters(10)) + assertEquals(2944.0, precisionBitsToMeters(11)) + assertEquals(1472.0, precisionBitsToMeters(12)) + assertEquals(736.0, precisionBitsToMeters(13)) + assertEquals(368.0, precisionBitsToMeters(14)) + assertEquals(184.0, precisionBitsToMeters(15)) + assertEquals(92.0, precisionBitsToMeters(16)) + assertEquals(46.0, precisionBitsToMeters(17)) + assertEquals(23.0, precisionBitsToMeters(18)) + assertEquals(11.5, precisionBitsToMeters(19)) + } + + @Test + fun precisionBitsToMeters_outOfRange_returnsZero() { + assertEquals(0.0, precisionBitsToMeters(0)) + assertEquals(0.0, precisionBitsToMeters(9)) + assertEquals(0.0, precisionBitsToMeters(20)) + assertEquals(0.0, precisionBitsToMeters(-1)) + } + + // --- intToHexColor --- + + @Test + fun intToHexColor_basicColors() { + assertEquals("#FF0000", intToHexColor(0xFFFF0000.toInt())) // Red + assertEquals("#00FF00", intToHexColor(0xFF00FF00.toInt())) // Green + assertEquals("#0000FF", intToHexColor(0xFF0000FF.toInt())) // Blue + assertEquals("#000000", intToHexColor(0xFF000000.toInt())) // Black + assertEquals("#FFFFFF", intToHexColor(0xFFFFFFFF.toInt())) // White + } + + @Test + fun intToHexColor_stripsAlpha() { + // Alpha channel should be stripped — only RGB remains + assertEquals("#6750A4", intToHexColor(0xFF6750A4.toInt())) + assertEquals("#6750A4", intToHexColor(0x006750A4)) + } + + @Test + fun intToHexColor_padsSixDigits() { + assertEquals("#000001", intToHexColor(1)) + assertEquals("#000100", intToHexColor(0x100)) + } + + // --- convertIntToEmoji --- + + @Test + fun convertIntToEmoji_bmpCharacter() { + // 0x2764 = Heart character (❤) + assertEquals("\u2764", convertIntToEmoji(0x2764)) + } + + @Test + fun convertIntToEmoji_supplementaryCharacter() { + // 0x1F4CD = Round Pushpin (📍) + assertEquals("\uD83D\uDCCD", convertIntToEmoji(0x1F4CD)) + } + + @Test + fun convertIntToEmoji_houseEmoji() { + // 0x1F3E0 = House (🏠) + val result = convertIntToEmoji(0x1F3E0) + assertEquals(2, result.length) // Surrogate pair + } + + @Test + fun convertIntToEmoji_maxBmpCharacter() { + val result = convertIntToEmoji(0xFFFF) + assertEquals(1, result.length) + } +} From a0deb90deccf63997ee5233806cc8feb5636d455 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 13:36:04 -0500 Subject: [PATCH 10/23] =?UTF-8?q?refactor(map):=20polish=20=E2=80=94=20DRY?= =?UTF-8?q?=20coordinate=20helpers,=20tighter=20visibility,=20dead=20code?= =?UTF-8?q?=20removal,=20null=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract toGeoPositionOrNull() into MapConstants.kt, replacing 8 duplicated coordinate-conversion patterns across GeoJsonConverters, TracerouteLayers, TracerouteMap, NodeTrackMap, InlineMap, and MapScreen - Extract typedFeatureCollection() helper to centralize the single unavoidable UNCHECKED_CAST, eliminating 9 scattered @Suppress annotations - Fix hardcoded style URI in InlineMap — now uses MapStyle.OpenStreetMap.toBaseStyle() - Tighten visibility: internal on MapButton, NodeTrackLayers, TracerouteLayers; private on BaseMapViewModel.nodes - Fix null safety: replace waypoint!!.id with safe mapNotNull pattern - Remove dead code: getUser(), myId (BaseMapViewModel); mapStyleId, applicationId, setDestNum, mapPrefs (NodeMapViewModel) - Remove redundant empty onFrame={} in MaplibreMapContent - Rename COORDINATE_PRECISION to FORMAT_DECIMAL_FACTOR in EditWaypointDialog - Update stale KDoc on BaseMapViewModel and MapButton; add KDoc on FeatureMapModule, LayerType, MapLayerItem, MapNavigation.mapGraph - Add 11 new tests: toGeoPositionOrNull (4), typedFeatureCollection (1), convertIntToEmoji fallback (1), combined filters (1), MapStyle.toBaseStyle (3), MapStyle defaults (1) --- .../feature/map/BaseMapViewModel.kt | 15 ++--- .../org/meshtastic/feature/map/MapScreen.kt | 11 +--- .../map/component/EditWaypointDialog.kt | 6 +- .../feature/map/component/InlineMap.kt | 23 +++----- .../feature/map/component/MapButton.kt | 7 +-- .../map/component/MaplibreMapContent.kt | 1 - .../feature/map/component/NodeTrackLayers.kt | 2 +- .../feature/map/component/NodeTrackMap.kt | 17 ++---- .../feature/map/component/TracerouteLayers.kt | 24 +++----- .../feature/map/component/TracerouteMap.kt | 15 ++--- .../feature/map/di/FeatureMapModule.kt | 1 + .../meshtastic/feature/map/model/MapLayer.kt | 2 + .../feature/map/navigation/MapNavigation.kt | 1 + .../feature/map/node/NodeMapViewModel.kt | 13 ----- .../feature/map/util/GeoJsonConverters.kt | 50 +++++++--------- .../feature/map/util/MapConstants.kt | 12 ++++ .../feature/map/BaseMapViewModelTest.kt | 30 ++++++++++ .../feature/map/model/MapStyleTest.kt | 47 +++++++++++++++ .../feature/map/util/GeoJsonConvertersTest.kt | 57 +++++++++++++++++++ 19 files changed, 211 insertions(+), 123 deletions(-) create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt 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 d837e9f09e..bca8b0f4cd 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 @@ -46,9 +46,7 @@ import org.meshtastic.proto.Waypoint /** * Shared base ViewModel for the map feature, providing node data, waypoints, map filter preferences, and traceroute - * overlay state. - * - * Platform-specific map ViewModels (fdroid/google) extend this to add flavor-specific map provider logic. + * overlay state. [MapViewModel] extends this with camera persistence and map style management. */ @Suppress("TooManyFunctions") open class BaseMapViewModel( @@ -65,14 +63,12 @@ open class BaseMapViewModel( val myNodeNum get() = myNodeInfo.value?.myNodeNum - val myId = nodeRepository.myId - val isConnected = radioController.connectionState .map { it is org.meshtastic.core.model.ConnectionState.Connected } .stateInWhileSubscribed(initialValue = false) - val nodes: StateFlow> = + private val nodes: StateFlow> = nodeRepository .getNodes() .map { nodes -> nodes.filterNot { node -> node.isIgnored } } @@ -88,8 +84,8 @@ open class BaseMapViewModel( .getWaypoints() .mapLatest { list -> list - .filter { it.waypoint != null } - .associateBy { packet -> packet.waypoint!!.id } + .mapNotNull { packet -> packet.waypoint?.let { wpt -> wpt.id to packet } } + .toMap() .filterValues { val expire = it.waypoint?.expire ?: 0 expire == 0 || expire.toLong() > nowSeconds @@ -141,9 +137,6 @@ open class BaseMapViewModel( mapPrefs.setLastHeardTrackFilter(filter.seconds) } - open fun getUser(userId: String?) = - nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST) - fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) fun deleteWaypoint(id: Int) = diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 80002f3850..daedbfe0df 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -49,7 +49,7 @@ import org.meshtastic.feature.map.component.MapFilterDropdown import org.meshtastic.feature.map.component.MapStyleSelector import org.meshtastic.feature.map.component.MaplibreMapContent import org.meshtastic.feature.map.model.MapStyle -import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.maplibre.spatialk.geojson.Position as GeoPosition private const val WAYPOINT_ZOOM = 15.0 @@ -118,13 +118,8 @@ fun MapScreen( val wpId = selectedWaypointId ?: return@LaunchedEffect val packet = waypoints[wpId] ?: return@LaunchedEffect val wpt = packet.waypoint ?: return@LaunchedEffect - val lat = (wpt.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (wpt.longitude_i ?: 0) * COORDINATE_SCALE - if (lat != 0.0 || lng != 0.0) { - cameraState.animateTo( - CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = WAYPOINT_ZOOM), - ) - } + val geoPos = toGeoPositionOrNull(wpt.latitude_i, wpt.longitude_i) ?: return@LaunchedEffect + cameraState.animateTo(CameraPosition(target = geoPos, zoom = WAYPOINT_ZOOM)) } @Suppress("ViewModelForwarding") diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index ccea107adf..5a2b408e99 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -55,8 +55,8 @@ import org.maplibre.spatialk.geojson.Position as GeoPosition private const val MAX_NAME_LENGTH = 29 private const val MAX_DESCRIPTION_LENGTH = 99 -private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin -private const val COORDINATE_PRECISION = 1_000_000L +private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin (📍) — same as PIN_EMOJI in GeoJsonConverters +private const val FORMAT_DECIMAL_FACTOR = 1_000_000L /** * Dialog for creating or editing a waypoint on the map. @@ -181,7 +181,7 @@ private fun Double.formatCoord(): String { val negative = this < 0 val absVal = abs(this) val wholePart = absVal.toLong() - val fracPart = ((absVal - wholePart) * COORDINATE_PRECISION + 0.5).toLong() + val fracPart = ((absVal - wholePart) * FORMAT_DECIMAL_FACTOR + 0.5).toLong() val fracStr = fracPart.toString().padStart(6, '0') return "${if (negative) "-" else ""}$wholePart.$fracStr" } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index b13af0cd3c..88d0325a03 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -32,14 +32,13 @@ import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.rememberGeoJsonSource -import org.maplibre.compose.style.BaseStyle import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node -import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.feature.map.util.precisionBitsToMeters -import org.maplibre.spatialk.geojson.Position as GeoPosition +import org.meshtastic.feature.map.util.toGeoPositionOrNull private const val DEFAULT_ZOOM = 15.0 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f @@ -55,27 +54,19 @@ private const val METERS_PER_PIXEL_ZOOM15 = 4.773 @Composable fun InlineMap(node: Node, modifier: Modifier = Modifier) { val position = node.validPosition ?: return - val lat = (position.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (position.longitude_i ?: 0) * COORDINATE_SCALE - if (lat == 0.0 && lng == 0.0) return + val geoPos = toGeoPositionOrNull(position.latitude_i, position.longitude_i) ?: return key(node.num) { - val cameraState = - rememberCameraState( - firstPosition = - CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = DEFAULT_ZOOM), - ) + val cameraState = rememberCameraState(firstPosition = CameraPosition(target = geoPos, zoom = DEFAULT_ZOOM)) val nodeFeature = - remember(node.num, lat, lng) { - FeatureCollection( - listOf(Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = null)), - ) + remember(node.num, geoPos) { + FeatureCollection(listOf(Feature(geometry = Point(geoPos), properties = null))) } MaplibreMap( modifier = modifier, - baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/liberty"), + baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState, options = MapOptions(gestureOptions = GestureOptions.AllDisabled, ornamentOptions = OrnamentOptions.AllDisabled), diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt index 3234de5871..26bd8d5ba6 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt @@ -24,12 +24,9 @@ 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. - */ +/** A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance. */ @Composable -fun MapButton( +internal fun MapButton( icon: ImageVector, contentDescription: String, onClick: () -> Unit, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 346d5bf9d8..80032ed0cc 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -125,7 +125,6 @@ fun MaplibreMapContent( }, onMapLoadFinished = onMapLoadFinished, onMapLoadFailed = onMapLoadFailed, - onFrame = {}, ) { // --- Terrain hillshade overlay --- if (showHillshade) { diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt index 8fdcf0e33f..748e2bcdca 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt @@ -44,7 +44,7 @@ private const val SELECTED_OPACITY = 0.9f * and OSMDroid Polyline overlay implementations. */ @Composable -fun NodeTrackLayers( +internal fun NodeTrackLayers( positions: List, selectedPositionTime: Int? = null, onPositionSelected: ((Int) -> Unit)? = null, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index 5ea9b5ef6f..6f8f34d685 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -30,9 +30,8 @@ import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.feature.map.model.MapStyle -import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.proto.Position -import org.maplibre.spatialk.geojson.Position as GeoPosition private const val DEFAULT_TRACK_ZOOM = 13.0 private const val BOUNDS_PADDING_DP = 48 @@ -53,13 +52,7 @@ fun NodeTrackMap( onPositionSelected: ((Int) -> Unit)? = null, ) { val geoPositions = - remember(positions) { - positions.mapNotNull { pos -> - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null - } - } + remember(positions) { positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } } val center = remember(geoPositions) { geoPositions.firstOrNull() } @@ -69,8 +62,8 @@ fun NodeTrackMap( val lats = geoPositions.map { it.latitude } val lngs = geoPositions.map { it.longitude } BoundingBox( - southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()), - northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()), + southwest = org.maplibre.spatialk.geojson.Position(longitude = lngs.min(), latitude = lats.min()), + northeast = org.maplibre.spatialk.geojson.Position(longitude = lngs.max(), latitude = lats.max()), ) } @@ -78,7 +71,7 @@ fun NodeTrackMap( rememberCameraState( firstPosition = CameraPosition( - target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0), + target = center ?: org.maplibre.spatialk.geojson.Position(longitude = 0.0, latitude = 0.0), zoom = DEFAULT_TRACK_ZOOM, ), ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index 45369590e9..11a17b912f 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -40,7 +40,8 @@ import org.maplibre.spatialk.geojson.LineString import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay -import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.feature.map.util.toGeoPositionOrNull +import org.meshtastic.feature.map.util.typedFeatureCollection import org.maplibre.spatialk.geojson.Position as GeoPosition private val ForwardRouteColor = Color(0xFF4CAF50) @@ -54,7 +55,7 @@ private const val ROUTE_OPACITY = 0.8f * polyline implementations. */ @Composable -fun TracerouteLayers( +internal fun TracerouteLayers( overlay: TracerouteOverlay?, nodePositions: Map, nodes: Map, @@ -130,9 +131,7 @@ private fun buildTracerouteGeoJson( ): TracerouteGeoJsonData { fun nodeToGeoPosition(nodeNum: Int): GeoPosition? { val pos = nodePositions[nodeNum] ?: return null - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - return if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat) + return toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } // Build forward route line @@ -144,11 +143,9 @@ private fun buildTracerouteGeoJson( geometry = LineString(forwardCoords), properties = buildJsonObject { put("direction", "forward") }, ) - @Suppress("UNCHECKED_CAST") - FeatureCollection(listOf(feature)) as FeatureCollection + typedFeatureCollection(listOf(feature)) } else { - @Suppress("UNCHECKED_CAST") - FeatureCollection(emptyList>()) as FeatureCollection + typedFeatureCollection(emptyList>()) } // Build return route line @@ -160,11 +157,9 @@ private fun buildTracerouteGeoJson( geometry = LineString(returnCoords), properties = buildJsonObject { put("direction", "return") }, ) - @Suppress("UNCHECKED_CAST") - FeatureCollection(listOf(feature)) as FeatureCollection + typedFeatureCollection(listOf(feature)) } else { - @Suppress("UNCHECKED_CAST") - FeatureCollection(emptyList>()) as FeatureCollection + typedFeatureCollection(emptyList>()) } // Build hop marker points @@ -185,10 +180,9 @@ private fun buildTracerouteGeoJson( ) } - @Suppress("UNCHECKED_CAST") return TracerouteGeoJsonData( forwardLine = forwardLine, returnLine = returnLine, - hopFeatures = FeatureCollection(hopFeatures) as FeatureCollection, + hopFeatures = typedFeatureCollection(hopFeatures), ) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index 4f1a69309d..042ad83656 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -32,9 +32,8 @@ import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.feature.map.model.MapStyle -import org.meshtastic.feature.map.util.COORDINATE_SCALE +import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.proto.Position -import org.maplibre.spatialk.geojson.Position as GeoPosition private const val DEFAULT_TRACEROUTE_ZOOM = 10.0 private const val BOUNDS_PADDING_DP = 64 @@ -60,11 +59,7 @@ fun TracerouteMap( ) { val geoPositions = remember(tracerouteNodePositions) { - tracerouteNodePositions.values.mapNotNull { pos -> - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null - } + tracerouteNodePositions.values.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } } val center = remember(geoPositions) { geoPositions.firstOrNull() } @@ -75,8 +70,8 @@ fun TracerouteMap( val lats = geoPositions.map { it.latitude } val lngs = geoPositions.map { it.longitude } BoundingBox( - southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()), - northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()), + southwest = org.maplibre.spatialk.geojson.Position(longitude = lngs.min(), latitude = lats.min()), + northeast = org.maplibre.spatialk.geojson.Position(longitude = lngs.max(), latitude = lats.max()), ) } @@ -84,7 +79,7 @@ fun TracerouteMap( rememberCameraState( firstPosition = CameraPosition( - target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0), + target = center ?: org.maplibre.spatialk.geojson.Position(longitude = 0.0, latitude = 0.0), zoom = DEFAULT_TRACEROUTE_ZOOM, ), ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt index a6ff74b174..d8fdd4e789 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/di/FeatureMapModule.kt @@ -19,6 +19,7 @@ package org.meshtastic.feature.map.di import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +/** Koin module for the map feature. Scans [org.meshtastic.feature.map] for annotated dependencies. */ @Module @ComponentScan("org.meshtastic.feature.map") class FeatureMapModule diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt index 82572ef8df..1d26cff85e 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt @@ -18,11 +18,13 @@ package org.meshtastic.feature.map.model import kotlin.uuid.Uuid +/** Supported custom overlay layer formats. */ enum class LayerType { KML, GEOJSON, } +/** A user-importable map overlay layer (KML or GeoJSON file). */ data class MapLayerItem( val id: String = Uuid.random().toString(), val name: String, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt index ab526e973d..da6d246267 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt @@ -25,6 +25,7 @@ import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.feature.map.MapScreen import org.meshtastic.feature.map.MapViewModel +/** Registers the map feature's navigation entries into a Navigation 3 [EntryProviderScope]. */ fun EntryProviderScope.mapGraph(backStack: NavBackStack) { entry { args -> val viewModel = koinViewModel() 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 9390913099..7e96ce6c1c 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 @@ -28,9 +28,7 @@ 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.common.BuildConfigProvider 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 @@ -43,8 +41,6 @@ class NodeMapViewModel( savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository, meshLogRepository: MeshLogRepository, - buildConfigProvider: BuildConfigProvider, - private val mapPrefs: MapPrefs, ) : ViewModel() { private val destNumFromRoute = savedStateHandle.get("destNum") private val manualDestNum = MutableStateFlow(null) @@ -52,18 +48,12 @@ class NodeMapViewModel( private val destNumFlow = combine(MutableStateFlow(destNumFromRoute), manualDestNum) { route, manual -> manual ?: route ?: 0 } - fun setDestNum(num: Int) { - manualDestNum.value = num - } - val node = destNumFlow .flatMapLatest { destNum -> nodeRepository.nodeDBbyNum.mapLatest { it[destNum] } } .distinctUntilChanged() .stateInWhileSubscribed(initialValue = null) - val applicationId = buildConfigProvider.applicationId - private val ourNodeNumFlow = nodeRepository.myNodeInfo.map { it?.myNodeNum }.distinctUntilChanged() val positionLogs: StateFlow> = @@ -84,7 +74,4 @@ class NodeMapViewModel( } } .stateInWhileSubscribed(initialValue = emptyList()) - - val mapStyleId: Int - get() = mapPrefs.mapStyle.value } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index cbff63e81b..9bf687512e 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -21,11 +21,11 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection +import org.maplibre.spatialk.geojson.Geometry import org.maplibre.spatialk.geojson.LineString import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node -import org.maplibre.spatialk.geojson.Position as GeoPosition private const val MIN_PRECISION_BITS = 10 private const val MAX_PRECISION_BITS = 19 @@ -35,9 +35,7 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature val features = nodes.mapNotNull { node -> val pos = node.validPosition ?: return@mapNotNull null - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - if (lat == 0.0 && lng == 0.0) return@mapNotNull null + val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null val colors = node.colors val props = buildJsonObject { @@ -57,11 +55,10 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature put("precision_meters", precisionBitsToMeters(pos.precision_bits ?: 0)) } - Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + Feature(geometry = Point(geoPos), properties = props) } - @Suppress("UNCHECKED_CAST") - return FeatureCollection(features) as FeatureCollection + return typedFeatureCollection(features) } /** Convert waypoints to a GeoJSON [FeatureCollection]. */ @@ -69,9 +66,7 @@ fun waypointsToFeatureCollection(waypoints: Map): FeatureCollec val features = waypoints.values.mapNotNull { packet -> val waypoint = packet.waypoint ?: return@mapNotNull null - val lat = (waypoint.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (waypoint.longitude_i ?: 0) * COORDINATE_SCALE - if (lat == 0.0 && lng == 0.0) return@mapNotNull null + val geoPos = toGeoPositionOrNull(waypoint.latitude_i, waypoint.longitude_i) ?: return@mapNotNull null val emoji = if (waypoint.icon != 0) convertIntToEmoji(waypoint.icon) else PIN_EMOJI @@ -85,21 +80,15 @@ fun waypointsToFeatureCollection(waypoints: Map): FeatureCollec put("expire", waypoint.expire) } - Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + Feature(geometry = Point(geoPos), properties = props) } - @Suppress("UNCHECKED_CAST") - return FeatureCollection(features) as FeatureCollection + return typedFeatureCollection(features) } /** Convert position history to a GeoJSON [LineString] for track rendering. */ fun positionsToLineString(positions: List): FeatureCollection { - val coords = - positions.mapNotNull { pos -> - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat) - } + val coords = positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } if (coords.size < 2) return FeatureCollection(emptyList()) @@ -107,17 +96,14 @@ fun positionsToLineString(positions: List): Featu val feature = Feature(geometry = LineString(coords), properties = props) - @Suppress("UNCHECKED_CAST") - return FeatureCollection(listOf(feature)) as FeatureCollection + return typedFeatureCollection(listOf(feature)) } /** Convert position history to individual point features with time metadata. */ fun positionsToPointFeatures(positions: List): FeatureCollection { val features = positions.mapNotNull { pos -> - val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE - val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE - if (lat == 0.0 && lng == 0.0) return@mapNotNull null + val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null val props = buildJsonObject { put("time", (pos.time ?: 0).toString()) @@ -126,11 +112,10 @@ fun positionsToPointFeatures(positions: List): Fe put("sats_in_view", pos.sats_in_view ?: 0) } - Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props) + Feature(geometry = Point(geoPos), properties = props) } - @Suppress("UNCHECKED_CAST") - return FeatureCollection(features) as FeatureCollection + return typedFeatureCollection(features) } /** Approximate meters of positional uncertainty from precision_bits (10-19). */ @@ -149,7 +134,16 @@ fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) { else -> 0.0 } -private const val PIN_EMOJI = "\uD83D\uDCCD" +private const val PIN_EMOJI = "\uD83D\uDCCD" // U+1F4CD Round Pushpin — same as DEFAULT_EMOJI in EditWaypointDialog + +/** + * Wraps [FeatureCollection] constructor with an unchecked cast to the desired type parameters. Centralizes the single + * unavoidable cast required by the spatialk GeoJSON API. + */ +@Suppress("UNCHECKED_CAST") +internal fun typedFeatureCollection(features: List>): FeatureCollection = + FeatureCollection(features) as FeatureCollection + private const val BMP_MAX = 0xFFFF private const val SUPPLEMENTARY_OFFSET = 0x10000 private const val HALF_SHIFT = 10 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt index cdecf8ecdc..59325300d4 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt @@ -16,5 +16,17 @@ */ package org.meshtastic.feature.map.util +import org.maplibre.spatialk.geojson.Position as GeoPosition + /** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */ const val COORDINATE_SCALE = 1e-7 + +/** + * Convert Meshtastic integer microdegree coordinates to a [GeoPosition], returning `null` if both latitude and + * longitude are zero (indicating no valid position). + */ +fun toGeoPositionOrNull(latI: Int?, lngI: Int?): GeoPosition? { + val lat = (latI ?: 0) * COORDINATE_SCALE + val lng = (lngI ?: 0) * COORDINATE_SCALE + return if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat) +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index fea1de0e00..d9d0629aa2 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -276,6 +276,36 @@ class BaseMapViewModelTest { } } + @Test + fun filteredNodes_combinedFavoritesAndLastHeard_filtersCorrectly() = runTest(testDispatcher) { + val now = nowSeconds.toInt() + val myNodeNum = 1 + nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum)) + val nodes = + listOf( + nodeWithPosition(myNodeNum, lastHeard = now), // my node — always visible + nodeWithPosition(2, isFavorite = true, lastHeard = now), // favorite + recent + nodeWithPosition(3, isFavorite = true, lastHeard = now - 7200), // favorite + stale + nodeWithPosition(4, isFavorite = false, lastHeard = now), // non-favorite + recent + ) + nodeRepository.setNodes(nodes) + + // Enable both filters + viewModel.toggleOnlyFavorites() + viewModel.setLastHeardFilter(LastHeardFilter.OneHour) + + viewModel.filteredNodes.test { + val result = expectMostRecentItem() + val nodeNums = result.map { it.num }.toSet() + // My node always visible, favorite+recent visible, favorite+stale filtered, non-favorite filtered + assertTrue(myNodeNum in nodeNums, "My node should always be visible") + assertTrue(2 in nodeNums, "Favorite + recent node should be visible") + assertFalse(3 in nodeNums, "Favorite + stale node should be filtered out by lastHeard") + assertFalse(4 in nodeNums, "Non-favorite node should be filtered out by favorites filter") + cancelAndIgnoreRemainingEvents() + } + } + // ---- getNodeOrFallback ---- @Test diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt new file mode 100644 index 0000000000..dc36f1c9ae --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.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.map.model + +import org.maplibre.compose.style.BaseStyle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class MapStyleTest { + + @Test + fun toBaseStyle_returnsUriWithCorrectStyleUri() { + for (style in MapStyle.entries) { + val baseStyle = style.toBaseStyle() + assertIs(baseStyle) + assertEquals(style.styleUri, baseStyle.uri) + } + } + + @Test + fun allStyles_haveNonBlankUri() { + for (style in MapStyle.entries) { + assert(style.styleUri.isNotBlank()) { "${style.name} has a blank styleUri" } + } + } + + @Test + fun openStreetMap_isDefault() { + // Verify OpenStreetMap is the first entry (used as default throughout the app) + assertEquals(MapStyle.OpenStreetMap, MapStyle.entries.first()) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt index 5ce3452141..df1de8fe54 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt @@ -16,6 +16,8 @@ */ package org.meshtastic.feature.map.util +import org.maplibre.spatialk.geojson.Feature +import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.proto.Position @@ -23,6 +25,8 @@ import org.meshtastic.proto.User import org.meshtastic.proto.Waypoint import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @Suppress("MagicNumber") @@ -272,4 +276,57 @@ class GeoJsonConvertersTest { val result = convertIntToEmoji(0xFFFF) assertEquals(1, result.length) } + + @Test + fun convertIntToEmoji_negativeCodepoint_returnsNonEmptyString() { + // Negative code points wrap around in char conversion but should not crash + val result = convertIntToEmoji(-1) + assertTrue(result.isNotEmpty(), "Should return a non-empty string even for invalid code points") + } + + // --- toGeoPositionOrNull --- + + @Test + fun toGeoPositionOrNull_validCoords_returnsGeoPosition() { + val result = toGeoPositionOrNull(400000000, -740000000) + assertNotNull(result) + assertEquals(40.0, result.latitude, 0.001) + assertEquals(-74.0, result.longitude, 0.001) + } + + @Test + fun toGeoPositionOrNull_zeroCoords_returnsNull() { + val result = toGeoPositionOrNull(0, 0) + assertNull(result) + } + + @Test + fun toGeoPositionOrNull_nullCoords_returnsNull() { + val result = toGeoPositionOrNull(null, null) + assertNull(result) + } + + @Test + fun toGeoPositionOrNull_onlyLatNull_treatedAsZero() { + // null lat = 0, non-zero lng -> lat=0.0 && lng!=0.0 -> not both zero -> returns position + val result = toGeoPositionOrNull(null, 100000000) + assertNotNull(result) + assertEquals(0.0, result.latitude, 0.001) + assertEquals(10.0, result.longitude, 0.001) + } + + // --- typedFeatureCollection --- + + @Test + fun typedFeatureCollection_preservesFeatures() { + val features = + listOf( + Feature( + geometry = Point(org.maplibre.spatialk.geojson.Position(longitude = 1.0, latitude = 2.0)), + properties = null, + ), + ) + val result = typedFeatureCollection(features) + assertEquals(1, result.features.size) + } } From a3c32f180fa7d19d03880d455b5b35fe32deff33 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 14:01:15 -0500 Subject: [PATCH 11/23] =?UTF-8?q?fix(map):=20audit=20best=20practices=20?= =?UTF-8?q?=E2=80=94=20OnlyLogo=20ornaments,=20always-visible=20location?= =?UTF-8?q?=20puck,=20rounded=20line=20caps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrnamentOptions.AllEnabled → OnlyLogo since custom MapControlsOverlay already provides compass and controls (avoids duplicate native ornaments) - Location puck now visible whenever location is available, not only when tracking is enabled (standard map UX — blue dot always shows position) - Add LineCap.Round + LineJoin.Round to all route and track LineLayer instances for smooth corners instead of jagged defaults --- .../kotlin/org/meshtastic/feature/map/MapScreen.kt | 2 +- .../meshtastic/feature/map/component/MaplibreMapContent.kt | 2 +- .../org/meshtastic/feature/map/component/NodeTrackLayers.kt | 4 ++++ .../meshtastic/feature/map/component/TracerouteLayers.kt | 6 ++++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index daedbfe0df..043c4ec507 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -161,7 +161,7 @@ fun MapScreen( longPressPosition = null showWaypointDialog = true }, - locationState = if (isLocationTrackingEnabled && locationAvailable) locationState else null, + locationState = if (locationAvailable) locationState else null, ) // Auto-pan camera when location tracking is enabled diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 80032ed0cc..7299b387c0 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -118,7 +118,7 @@ fun MaplibreMapContent( modifier = modifier, baseStyle = baseStyle, cameraState = cameraState, - options = MapOptions(gestureOptions = gestureOptions, ornamentOptions = OrnamentOptions.AllEnabled), + options = MapOptions(gestureOptions = gestureOptions, ornamentOptions = OrnamentOptions.OnlyLogo), onMapLongClick = { position, _ -> onMapLongClick(position) ClickResult.Consume diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt index 748e2bcdca..fe38673c3c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt @@ -25,6 +25,8 @@ import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.eq import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.value.LineCap +import org.maplibre.compose.expressions.value.LineJoin import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.LineLayer import org.maplibre.compose.sources.GeoJsonData @@ -67,6 +69,8 @@ internal fun NodeTrackLayers( width = const(3.dp), color = const(TrackColor), // Blue opacity = const(TRACK_OPACITY), + cap = const(LineCap.Round), + join = const(LineJoin.Round), ) // Position marker points diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index 11a17b912f..58895e6243 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -29,6 +29,8 @@ import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.expressions.dsl.offset +import org.maplibre.compose.expressions.value.LineCap +import org.maplibre.compose.expressions.value.LineJoin import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.LineLayer import org.maplibre.compose.layers.SymbolLayer @@ -80,6 +82,8 @@ internal fun TracerouteLayers( width = const(3.dp), color = const(ForwardRouteColor), // Green opacity = const(ROUTE_OPACITY), + cap = const(LineCap.Round), + join = const(LineJoin.Round), ) } @@ -93,6 +97,8 @@ internal fun TracerouteLayers( color = const(ReturnRouteColor), // Red opacity = const(ROUTE_OPACITY), dasharray = const(listOf(2f, 1f)), + cap = const(LineCap.Round), + join = const(LineJoin.Round), ) } From 0b16123be31f3f370f94f1b49d5e894baba6dfe2 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 14:11:21 -0500 Subject: [PATCH 12/23] feat(map): material3 controls + latitude-aware precision circles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DisappearingScaleBar overlay (bottom-start) that auto-shows on zoom change and hides after 3 seconds, using CameraState.metersPerDpAtTarget - Add ExpandingAttributionButton overlay (bottom-end) for tile provider attribution display (legal compliance), auto-dismisses on map gesture - Thread StyleState from MapScreen → MaplibreMapContent → MaplibreMap to provide source attribution data for the attribution button - Use LocationPuckDefaults.colors() for Material 3 themed location puck (derives colors from MaterialTheme.colorScheme instead of hardcoded blue) - Replace hardcoded METERS_PER_PIXEL_ZOOM15 equatorial constant in InlineMap with CameraState.metersPerDpAtTarget for latitude-aware precision circles --- .../org/meshtastic/feature/map/MapScreen.kt | 21 +++++++++++++++++++ .../feature/map/component/InlineMap.kt | 10 ++++----- .../map/component/MaplibreMapContent.kt | 12 ++++++++++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 043c4ec507..17a0b50c2d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -40,6 +41,9 @@ import org.maplibre.compose.location.LocationTrackingEffect import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberUserLocationState import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.material3.DisappearingScaleBar +import org.maplibre.compose.material3.ExpandingAttributionButton +import org.maplibre.compose.style.rememberStyleState import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map import org.meshtastic.core.ui.component.MainAppBar @@ -53,6 +57,7 @@ import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.maplibre.spatialk.geojson.Position as GeoPosition private const val WAYPOINT_ZOOM = 15.0 +private val MAP_OVERLAY_PADDING = 16.dp /** * Main map screen composable. Uses MapLibre Compose Multiplatform to render an interactive map with mesh node markers, @@ -81,6 +86,7 @@ fun MapScreen( LaunchedEffect(waypointId) { viewModel.setWaypointId(waypointId) } val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition) + val styleState = rememberStyleState() var filterMenuExpanded by remember { mutableStateOf(false) } @@ -155,6 +161,7 @@ fun MapScreen( }, modifier = Modifier.fillMaxSize(), gestureOptions = gestureOptions, + styleState = styleState, onCameraMoved = { position -> viewModel.saveCameraPosition(position) }, onWaypointClick = { wpId -> editingWaypointId = wpId @@ -228,6 +235,20 @@ fun MapScreen( } }, ) + + // Scale bar — auto-shows on zoom change, hides after 3 seconds + DisappearingScaleBar( + metersPerDp = cameraState.metersPerDpAtTarget, + zoom = cameraState.position.zoom, + modifier = Modifier.align(Alignment.BottomStart).padding(MAP_OVERLAY_PADDING), + ) + + // Attribution button — shows tile provider attributions (legal compliance) + ExpandingAttributionButton( + cameraState = cameraState, + styleState = styleState, + modifier = Modifier.align(Alignment.BottomEnd).padding(MAP_OVERLAY_PADDING), + ) } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 88d0325a03..977e2bd528 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -44,9 +44,6 @@ private const val DEFAULT_ZOOM = 15.0 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f -/** Ground resolution at zoom 15 (equatorial): ~4.773 meters per pixel. */ -private const val METERS_PER_PIXEL_ZOOM15 = 4.773 - /** * A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the * Google Maps and OSMDroid inline map implementations. @@ -83,10 +80,11 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { strokeColor = const(Color.White), ) - // Precision circle — radius computed from precision_meters at zoom 15 + // Precision circle — radius computed from precision_meters using latitude-aware metersPerDp val precisionMeters = precisionBitsToMeters(position.precision_bits ?: 0) - if (precisionMeters > 0) { - val radiusDp = (precisionMeters / METERS_PER_PIXEL_ZOOM15).dp + val metersPerDp = cameraState.metersPerDpAtTarget + if (precisionMeters > 0 && metersPerDp > 0) { + val radiusDp = (precisionMeters / metersPerDp).dp CircleLayer( id = "inline-node-precision", source = source, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 7299b387c0..19871567d1 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -48,12 +48,15 @@ import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions +import org.maplibre.compose.material3.LocationPuckDefaults import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonOptions import org.maplibre.compose.sources.RasterDemEncoding import org.maplibre.compose.sources.rememberGeoJsonSource import org.maplibre.compose.sources.rememberRasterDemSource import org.maplibre.compose.style.BaseStyle +import org.maplibre.compose.style.StyleState +import org.maplibre.compose.style.rememberStyleState import org.maplibre.compose.util.ClickResult import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.DataPacket @@ -108,6 +111,7 @@ fun MaplibreMapContent( onMapLongClick: (GeoPosition) -> Unit, modifier: Modifier = Modifier, gestureOptions: GestureOptions = GestureOptions.Standard, + styleState: StyleState = rememberStyleState(), onCameraMoved: (CameraPosition) -> Unit = {}, onWaypointClick: (Int) -> Unit = {}, onMapLoadFinished: () -> Unit = {}, @@ -118,6 +122,7 @@ fun MaplibreMapContent( modifier = modifier, baseStyle = baseStyle, cameraState = cameraState, + styleState = styleState, options = MapOptions(gestureOptions = gestureOptions, ornamentOptions = OrnamentOptions.OnlyLogo), onMapLongClick = { position, _ -> onMapLongClick(position) @@ -148,7 +153,12 @@ fun MaplibreMapContent( // --- User location puck --- if (locationState != null) { - LocationPuck(idPrefix = "user-location", locationState = locationState, cameraState = cameraState) + LocationPuck( + idPrefix = "user-location", + locationState = locationState, + cameraState = cameraState, + colors = LocationPuckDefaults.colors(), + ) } } From c76aa0856d8bfbf7cba57916ba2c8475cc351490 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 14:45:50 -0500 Subject: [PATCH 13/23] refactor(map): DRY constants, shared bounding box, i18n fix, CI test fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract NODE_MARKER_RADIUS, MARKER_STROKE_WIDTH, PRECISION_CIRCLE_STROKE_ALPHA to MapConstants.kt — eliminates duplicates across MaplibreMapContent, InlineMap, and TracerouteLayers - Extract computeBoundingBox() utility — deduplicates identical code in NodeTrackMap and TracerouteMap - Replace hardcoded "Unknown" in TracerouteLayers with stringResource(Res.string.unknown) - Add ioDispatcher constructor parameter to BaseMapViewModel/MapViewModel — tests pass testDispatcher directly, eliminating flaky delay(100) race conditions - Remove dead manualDestNum flow from NodeMapViewModel, simplify destNumFlow - Tighten visibility: TracerouteNodeSelection, GeoJsonConverters, MapConstants, MapLayerItem/LayerType → internal - Remove redundant elvis operators on non-null proto fields (build warnings) - Fix assert() → assertTrue() in MapStyleTest for Kotlin/Native compatibility - Remove unnecessary !! assertions in GeoJsonConvertersTest - Add computeBoundingBox tests (null for <2 positions, correct bounds for 3+) --- .../feature/map/BaseMapViewModel.kt | 10 +++--- .../meshtastic/feature/map/MapViewModel.kt | 5 ++- .../feature/map/component/InlineMap.kt | 10 +++--- .../map/component/MaplibreMapContent.kt | 10 +++--- .../feature/map/component/NodeTrackMap.kt | 13 ++----- .../feature/map/component/TracerouteLayers.kt | 19 ++++++++--- .../feature/map/component/TracerouteMap.kt | 13 ++----- .../meshtastic/feature/map/model/MapLayer.kt | 4 +-- .../feature/map/node/NodeMapViewModel.kt | 34 ++++++++++++------- .../feature/map/util/GeoJsonConverters.kt | 29 +++++++++------- .../feature/map/util/MapConstants.kt | 31 +++++++++++++++-- .../feature/map/BaseMapViewModelTest.kt | 1 + .../feature/map/MapViewModelTest.kt | 11 +----- .../feature/map/model/MapStyleTest.kt | 3 +- .../feature/map/util/GeoJsonConvertersTest.kt | 32 ++++++++++++++--- 15 files changed, 141 insertions(+), 84 deletions(-) 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 bca8b0f4cd..4170929f50 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 @@ -17,6 +17,7 @@ package org.meshtastic.feature.map import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,7 +25,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import org.jetbrains.compose.resources.StringResource -import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node @@ -43,6 +43,7 @@ import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint +import org.meshtastic.core.common.util.ioDispatcher as defaultIoDispatcher /** * Shared base ViewModel for the map feature, providing node data, waypoints, map filter preferences, and traceroute @@ -54,6 +55,7 @@ open class BaseMapViewModel( protected val nodeRepository: NodeRepository, private val packetRepository: PacketRepository, private val radioController: RadioController, + private val ioDispatcher: CoroutineDispatcher = defaultIoDispatcher, ) : ViewModel() { val myNodeInfo = nodeRepository.myNodeInfo @@ -208,14 +210,14 @@ open class BaseMapViewModel( * @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( +internal 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( +internal fun BaseMapViewModel.tracerouteNodeSelection( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map, nodes: List, @@ -232,7 +234,7 @@ fun BaseMapViewModel.tracerouteNodeSelection( * * @param getNodeOrFallback Provides a [Node] for a given num, falling back to a stub if not in the DB. */ -fun tracerouteNodeSelection( +internal fun tracerouteNodeSelection( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map, nodes: List, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 60e5df0cd4..616d953b05 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.map import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -34,6 +35,7 @@ import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.feature.map.util.COORDINATE_SCALE import org.meshtastic.proto.Waypoint import org.maplibre.spatialk.geojson.Position as GeoPosition +import org.meshtastic.core.common.util.ioDispatcher as defaultIoDispatcher /** * Unified map ViewModel replacing the previous Google and F-Droid flavor-specific ViewModels. @@ -49,7 +51,8 @@ class MapViewModel( packetRepository: PacketRepository, radioController: RadioController, savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { + ioDispatcher: CoroutineDispatcher = defaultIoDispatcher, +) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController, ioDispatcher) { /** Currently selected waypoint to focus on map. */ private val selectedWaypointIdInternal = MutableStateFlow(savedStateHandle.get("waypointId")) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 977e2bd528..8f555895d1 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -37,12 +37,14 @@ import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH +import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS +import org.meshtastic.feature.map.util.PRECISION_CIRCLE_STROKE_ALPHA import org.meshtastic.feature.map.util.precisionBitsToMeters import org.meshtastic.feature.map.util.toGeoPositionOrNull private const val DEFAULT_ZOOM = 15.0 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f -private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f /** * A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the @@ -74,14 +76,14 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { CircleLayer( id = "inline-node-marker", source = source, - radius = const(8.dp), + radius = const(NODE_MARKER_RADIUS), color = const(Color(node.colors.second)), - strokeWidth = const(2.dp), + strokeWidth = const(MARKER_STROKE_WIDTH), strokeColor = const(Color.White), ) // Precision circle — radius computed from precision_meters using latitude-aware metersPerDp - val precisionMeters = precisionBitsToMeters(position.precision_bits ?: 0) + val precisionMeters = precisionBitsToMeters(position.precision_bits) val metersPerDp = cameraState.metersPerDpAtTarget if (precisionMeters > 0 && metersPerDp > 0) { val radiusDp = (precisionMeters / metersPerDp).dp diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 19871567d1..2f23615e64 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -61,6 +61,9 @@ import org.maplibre.compose.util.ClickResult import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node +import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH +import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS +import org.meshtastic.feature.map.util.PRECISION_CIRCLE_STROKE_ALPHA import org.meshtastic.feature.map.util.nodesToFeatureCollection import org.meshtastic.feature.map.util.waypointsToFeatureCollection import org.maplibre.spatialk.geojson.Position as GeoPosition @@ -69,7 +72,6 @@ private val NodeMarkerColor = Color(0xFF6750A4) private const val CLUSTER_RADIUS = 50 private const val CLUSTER_MIN_POINTS = 10 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f -private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f /** * Ground resolution at the equator: meters per pixel = 156543.03 / 2^zoom. We use an exponential(2) interpolation with @@ -198,7 +200,7 @@ private fun NodeMarkerLayers( radius = const(20.dp), color = const(NodeMarkerColor), // Material primary opacity = const(CLUSTER_OPACITY), - strokeWidth = const(2.dp), + strokeWidth = const(MARKER_STROKE_WIDTH), strokeColor = const(Color.White), onClick = { features -> val cluster = features.firstOrNull() ?: return@CircleLayer ClickResult.Pass @@ -230,9 +232,9 @@ private fun NodeMarkerLayers( id = "node-markers", source = nodesSource, filter = !feature.has("cluster"), - radius = const(8.dp), + radius = const(NODE_MARKER_RADIUS), color = feature["background_color"].convertToColor(const(NodeMarkerColor)), - strokeWidth = const(2.dp), + strokeWidth = const(MARKER_STROKE_WIDTH), strokeColor = const(Color.White), onClick = { features -> val nodeNum = features.firstOrNull()?.properties?.get("node_num")?.toString()?.toIntOrNull() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index 6f8f34d685..78091b1fca 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -28,8 +28,8 @@ import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions -import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.feature.map.util.computeBoundingBox import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.proto.Position @@ -56,16 +56,7 @@ fun NodeTrackMap( val center = remember(geoPositions) { geoPositions.firstOrNull() } - val boundingBox = - remember(geoPositions) { - if (geoPositions.size < 2) return@remember null - val lats = geoPositions.map { it.latitude } - val lngs = geoPositions.map { it.longitude } - BoundingBox( - southwest = org.maplibre.spatialk.geojson.Position(longitude = lngs.min(), latitude = lats.min()), - northeast = org.maplibre.spatialk.geojson.Position(longitude = lngs.max(), latitude = lats.max()), - ) - } + val boundingBox = remember(geoPositions) { computeBoundingBox(geoPositions) } val cameraState = rememberCameraState( diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index 58895e6243..9aa2826dc9 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.em import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import org.jetbrains.compose.resources.stringResource import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.feature @@ -42,6 +43,10 @@ import org.maplibre.spatialk.geojson.LineString import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.unknown +import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH +import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.feature.map.util.typedFeatureCollection import org.maplibre.spatialk.geojson.Position as GeoPosition @@ -65,8 +70,13 @@ internal fun TracerouteLayers( ) { if (overlay == null) return + val unknownNodeName = stringResource(Res.string.unknown) + // Build route line features - val routeData = remember(overlay, nodePositions, nodes) { buildTracerouteGeoJson(overlay, nodePositions, nodes) } + val routeData = + remember(overlay, nodePositions, nodes, unknownNodeName) { + buildTracerouteGeoJson(overlay, nodePositions, nodes, unknownNodeName) + } // Report mappable count via side effect (avoid state updates during composition) val mappableCount = routeData.hopFeatures.features.size @@ -108,9 +118,9 @@ internal fun TracerouteLayers( CircleLayer( id = "traceroute-hops", source = hopsSource, - radius = const(8.dp), + radius = const(NODE_MARKER_RADIUS), color = const(HopMarkerColor), // Purple - strokeWidth = const(2.dp), + strokeWidth = const(MARKER_STROKE_WIDTH), strokeColor = const(Color.White), ) SymbolLayer( @@ -134,6 +144,7 @@ private fun buildTracerouteGeoJson( overlay: TracerouteOverlay, nodePositions: Map, nodes: Map, + unknownNodeName: String, ): TracerouteGeoJsonData { fun nodeToGeoPosition(nodeNum: Int): GeoPosition? { val pos = nodePositions[nodeNum] ?: return null @@ -181,7 +192,7 @@ private fun buildTracerouteGeoJson( buildJsonObject { put("node_num", nodeNum) put("short_name", node?.user?.short_name ?: nodeNum.toUInt().toString(HEX_RADIX)) - put("long_name", node?.user?.long_name ?: "Unknown") + put("long_name", node?.user?.long_name ?: unknownNodeName) }, ) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index 042ad83656..68a93726d7 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -28,10 +28,10 @@ import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions -import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.feature.map.util.computeBoundingBox import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.proto.Position @@ -64,16 +64,7 @@ fun TracerouteMap( val center = remember(geoPositions) { geoPositions.firstOrNull() } - val boundingBox = - remember(geoPositions) { - if (geoPositions.size < 2) return@remember null - val lats = geoPositions.map { it.latitude } - val lngs = geoPositions.map { it.longitude } - BoundingBox( - southwest = org.maplibre.spatialk.geojson.Position(longitude = lngs.min(), latitude = lats.min()), - northeast = org.maplibre.spatialk.geojson.Position(longitude = lngs.max(), latitude = lats.max()), - ) - } + val boundingBox = remember(geoPositions) { computeBoundingBox(geoPositions) } val cameraState = rememberCameraState( diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt index 1d26cff85e..194aff6290 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapLayer.kt @@ -19,13 +19,13 @@ package org.meshtastic.feature.map.model import kotlin.uuid.Uuid /** Supported custom overlay layer formats. */ -enum class LayerType { +internal enum class LayerType { KML, GEOJSON, } /** A user-importable map overlay layer (KML or GeoJSON file). */ -data class MapLayerItem( +internal data class MapLayerItem( val id: String = Uuid.random().toString(), val name: String, val uriString: String? = null, 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 7e96ce6c1c..c620800cc9 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 @@ -20,13 +20,10 @@ 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.MeshLogRepository @@ -42,11 +39,9 @@ class NodeMapViewModel( nodeRepository: NodeRepository, meshLogRepository: MeshLogRepository, ) : ViewModel() { - private val destNumFromRoute = savedStateHandle.get("destNum") - private val manualDestNum = MutableStateFlow(null) + private val destNum = savedStateHandle.get("destNum") ?: 0 - private val destNumFlow = - combine(MutableStateFlow(destNumFromRoute), manualDestNum) { route, manual -> manual ?: route ?: 0 } + private val destNumFlow = MutableStateFlow(destNum) val node = destNumFlow @@ -57,21 +52,34 @@ class NodeMapViewModel( 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 - } + ourNodeNumFlow + .map { ourNodeNum -> 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 -> + .filterConsecutiveDuplicates { old, new -> old.time == new.time || (old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i) } - .toList() } } .stateInWhileSubscribed(initialValue = emptyList()) } + +/** + * Filters consecutive duplicate elements from a list, similar to [Sequence.distinctUntilChanged]. An element is + * considered a duplicate if [predicate] returns `true` for it and the previous element. + */ +private fun List.filterConsecutiveDuplicates(predicate: (old: T, new: T) -> Boolean): List { + if (size <= 1) return this + return buildList { + add(this@filterConsecutiveDuplicates.first()) + for (i in 1 until this@filterConsecutiveDuplicates.size) { + if (!predicate(this@filterConsecutiveDuplicates[i - 1], this@filterConsecutiveDuplicates[i])) { + add(this@filterConsecutiveDuplicates[i]) + } + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index 9bf687512e..e3ea84423c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -31,7 +31,7 @@ private const val MIN_PRECISION_BITS = 10 private const val MAX_PRECISION_BITS = 19 /** Convert a list of nodes to a GeoJSON [FeatureCollection] for map rendering. */ -fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): FeatureCollection { +internal fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): FeatureCollection { val features = nodes.mapNotNull { node -> val pos = node.validPosition ?: return@mapNotNull null @@ -51,8 +51,8 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature put("rssi", node.rssi) put("foreground_color", intToHexColor(colors.first)) put("background_color", intToHexColor(colors.second)) - put("has_precision", (pos.precision_bits ?: 0) in MIN_PRECISION_BITS..MAX_PRECISION_BITS) - put("precision_meters", precisionBitsToMeters(pos.precision_bits ?: 0)) + put("has_precision", pos.precision_bits in MIN_PRECISION_BITS..MAX_PRECISION_BITS) + put("precision_meters", precisionBitsToMeters(pos.precision_bits)) } Feature(geometry = Point(geoPos), properties = props) @@ -62,7 +62,7 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature } /** Convert waypoints to a GeoJSON [FeatureCollection]. */ -fun waypointsToFeatureCollection(waypoints: Map): FeatureCollection { +internal fun waypointsToFeatureCollection(waypoints: Map): FeatureCollection { val features = waypoints.values.mapNotNull { packet -> val waypoint = packet.waypoint ?: return@mapNotNull null @@ -87,7 +87,9 @@ fun waypointsToFeatureCollection(waypoints: Map): FeatureCollec } /** Convert position history to a GeoJSON [LineString] for track rendering. */ -fun positionsToLineString(positions: List): FeatureCollection { +internal fun positionsToLineString( + positions: List, +): FeatureCollection { val coords = positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } if (coords.size < 2) return FeatureCollection(emptyList()) @@ -100,16 +102,18 @@ fun positionsToLineString(positions: List): Featu } /** Convert position history to individual point features with time metadata. */ -fun positionsToPointFeatures(positions: List): FeatureCollection { +internal fun positionsToPointFeatures( + positions: List, +): FeatureCollection { val features = positions.mapNotNull { pos -> val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null val props = buildJsonObject { - put("time", (pos.time ?: 0).toString()) + put("time", pos.time.toString()) put("altitude", pos.altitude ?: 0) put("ground_speed", pos.ground_speed ?: 0) - put("sats_in_view", pos.sats_in_view ?: 0) + put("sats_in_view", pos.sats_in_view) } Feature(geometry = Point(geoPos), properties = props) @@ -120,7 +124,7 @@ fun positionsToPointFeatures(positions: List): Fe /** Approximate meters of positional uncertainty from precision_bits (10-19). */ @Suppress("MagicNumber") -fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) { +internal fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) { 10 -> 5886.0 11 -> 2944.0 12 -> 1472.0 @@ -137,12 +141,11 @@ fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) { private const val PIN_EMOJI = "\uD83D\uDCCD" // U+1F4CD Round Pushpin — same as DEFAULT_EMOJI in EditWaypointDialog /** - * Wraps [FeatureCollection] constructor with an unchecked cast to the desired type parameters. Centralizes the single - * unavoidable cast required by the spatialk GeoJSON API. + * Wraps [FeatureCollection] constructor with the desired type parameters. Centralizes the typed constructor call + * required by the spatialk GeoJSON API. */ -@Suppress("UNCHECKED_CAST") internal fun typedFeatureCollection(features: List>): FeatureCollection = - FeatureCollection(features) as FeatureCollection + FeatureCollection(features) private const val BMP_MAX = 0xFFFF private const val SUPPLEMENTARY_OFFSET = 0x10000 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt index 59325300d4..df78d4b3cb 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt @@ -16,17 +16,44 @@ */ package org.meshtastic.feature.map.util +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.maplibre.spatialk.geojson.BoundingBox import org.maplibre.spatialk.geojson.Position as GeoPosition /** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */ -const val COORDINATE_SCALE = 1e-7 +internal const val COORDINATE_SCALE = 1e-7 + +/** Standard radius for node and hop marker circles across all map composables. */ +internal val NODE_MARKER_RADIUS: Dp = 8.dp + +/** Standard stroke width for marker circle outlines across all map composables. */ +internal val MARKER_STROKE_WIDTH: Dp = 2.dp + +/** Opacity for precision circle strokes (shared between main map and inline map). */ +internal const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f /** * Convert Meshtastic integer microdegree coordinates to a [GeoPosition], returning `null` if both latitude and * longitude are zero (indicating no valid position). */ -fun toGeoPositionOrNull(latI: Int?, lngI: Int?): GeoPosition? { +internal fun toGeoPositionOrNull(latI: Int?, lngI: Int?): GeoPosition? { val lat = (latI ?: 0) * COORDINATE_SCALE val lng = (lngI ?: 0) * COORDINATE_SCALE return if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat) } + +/** + * Compute a [BoundingBox] that encloses all [positions], or `null` if fewer than 2 positions are provided. Used by + * [NodeTrackMap][org.meshtastic.feature.map.component.NodeTrackMap] and + * [TracerouteMap][org.meshtastic.feature.map.component.TracerouteMap] to fit the camera to track/route bounds. + */ +internal fun computeBoundingBox(positions: List): BoundingBox? { + if (positions.size < 2) return null + val lats = positions.map { it.latitude } + val lngs = positions.map { it.longitude } + return BoundingBox( + southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()), + northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()), + ) +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index d9d0629aa2..a6f698181a 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -76,6 +76,7 @@ class BaseMapViewModelTest { nodeRepository = nodeRepository, packetRepository = packetRepository, radioController = radioController, + ioDispatcher = testDispatcher, ) private fun nodeWithPosition( diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt index a875d9e2a8..12321cc524 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt @@ -23,7 +23,6 @@ import dev.mokkery.every import dev.mokkery.mock import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -78,6 +77,7 @@ class MapViewModelTest { packetRepository = packetRepository, radioController = radioController, savedStateHandle = savedStateHandle, + ioDispatcher = testDispatcher, ) @Test @@ -198,9 +198,6 @@ class MapViewModelTest { position = position, ) - // sendWaypoint dispatches to ioDispatcher; give it time to execute - delay(100) - // FakeRadioController.getPacketId() returns 1, and sendMessage appends to sentPackets assertEquals(1, radioController.sentPackets.size) val sent = radioController.sentPackets.first() @@ -229,8 +226,6 @@ class MapViewModelTest { position = null, ) - delay(100) - assertEquals(1, radioController.sentPackets.size) val wpt = radioController.sentPackets.first().waypoint!! assertEquals(42, wpt.id) // Retains existing ID @@ -256,8 +251,6 @@ class MapViewModelTest { position = position, ) - delay(100) - assertEquals(1, radioController.sentPackets.size) assertEquals(99, radioController.sentPackets.first().waypoint!!.locked_to) } @@ -274,8 +267,6 @@ class MapViewModelTest { position = null, ) - delay(100) - assertEquals(1, radioController.sentPackets.size) val wpt = radioController.sentPackets.first().waypoint!! assertEquals(0, wpt.latitude_i) diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt index dc36f1c9ae..e630097658 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt @@ -20,6 +20,7 @@ import org.maplibre.compose.style.BaseStyle import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertTrue class MapStyleTest { @@ -35,7 +36,7 @@ class MapStyleTest { @Test fun allStyles_haveNonBlankUri() { for (style in MapStyle.entries) { - assert(style.styleUri.isNotBlank()) { "${style.name} has a blank styleUri" } + assertTrue(style.styleUri.isNotBlank(), "${style.name} has a blank styleUri") } } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt index df1de8fe54..1b5502f80f 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt @@ -76,7 +76,7 @@ class GeoJsonConvertersTest { assertEquals(40.0, coords.latitude, 0.001) assertEquals(-74.0, coords.longitude, 0.001) - val props = feature.properties!! + val props = feature.properties assertEquals(42, props["node_num"]?.toString()?.toIntOrNull()) assertEquals("\"AB\"", props["short_name"].toString()) assertEquals("\"Alpha Bravo\"", props["long_name"].toString()) @@ -88,7 +88,7 @@ class GeoJsonConvertersTest { fun nodesToFeatureCollection_isMyNodeFalseForOtherNodes() { val node = Node(num = 10, position = Position(latitude_i = 400000000, longitude_i = -740000000)) val result = nodesToFeatureCollection(listOf(node), myNodeNum = 42) - val props = result.features.first().properties!! + val props = result.features.first().properties assertEquals("false", props["is_my_node"].toString()) } @@ -141,7 +141,7 @@ class GeoJsonConvertersTest { assertEquals(51.5, coords.latitude, 0.001) assertEquals(-0.1, coords.longitude, 0.001) - val props = feature.properties!! + val props = feature.properties assertEquals(99, props["waypoint_id"]?.toString()?.toIntOrNull()) assertEquals("\"Home\"", props["name"].toString()) } @@ -197,7 +197,7 @@ class GeoJsonConvertersTest { val positions = listOf(Position(latitude_i = 400000000, longitude_i = -740000000, time = 1000, altitude = 100)) val result = positionsToPointFeatures(positions) assertEquals(1, result.features.size) - val props = result.features.first().properties!! + val props = result.features.first().properties assertEquals("\"1000\"", props["time"].toString()) assertEquals(100, props["altitude"]?.toString()?.toIntOrNull()) } @@ -329,4 +329,28 @@ class GeoJsonConvertersTest { val result = typedFeatureCollection(features) assertEquals(1, result.features.size) } + + // --- computeBoundingBox --- + + @Test + fun computeBoundingBox_fewerThanTwoPositions_returnsNull() { + assertNull(computeBoundingBox(emptyList())) + assertNull(computeBoundingBox(listOf(org.maplibre.spatialk.geojson.Position(longitude = 1.0, latitude = 2.0)))) + } + + @Test + fun computeBoundingBox_twoOrMorePositions_returnsBounds() { + val positions = + listOf( + org.maplibre.spatialk.geojson.Position(longitude = -74.0, latitude = 40.0), + org.maplibre.spatialk.geojson.Position(longitude = -73.0, latitude = 41.0), + org.maplibre.spatialk.geojson.Position(longitude = -75.0, latitude = 39.0), + ) + val bbox = computeBoundingBox(positions) + assertNotNull(bbox) + assertEquals(39.0, bbox.southwest.latitude, 0.001) + assertEquals(-75.0, bbox.southwest.longitude, 0.001) + assertEquals(41.0, bbox.northeast.latitude, 0.001) + assertEquals(-73.0, bbox.northeast.longitude, 0.001) + } } From 177f97abdd5eda337edb52c9fbbe5c9a7ac8bf7d Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 21:55:48 -0500 Subject: [PATCH 14/23] feat(map): online status indicators, zoom-to-fit-all, GeoJSON enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add is_online and battery_level properties to node GeoJSON features - Node marker strokes now show green (online) or gray (offline) using switch/condition expressions on the is_online boolean property - Node labels display a colored status dot (●) via format/span rich text - Add 'Zoom to Fit All Nodes' action in filter dropdown menu, computing bounding box from filteredNodes and animating camera with animateTo() - Add 4 new GeoJSON converter tests for is_online and battery_level --- .../composeResources/values/strings.xml | 1 + .../org/meshtastic/feature/map/MapScreen.kt | 11 +++++ .../map/component/MapFilterDropdown.kt | 17 +++++++ .../map/component/MaplibreMapContent.kt | 30 ++++++++++-- .../feature/map/util/GeoJsonConverters.kt | 2 + .../feature/map/util/GeoJsonConvertersTest.kt | 47 +++++++++++++++++++ 6 files changed, 104 insertions(+), 4 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index b1ccd95560..aa8c1fdac8 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1511,5 +1511,6 @@ You 简体中文 繁體中文 + Zoom to Fit All Nodes diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 17a0b50c2d..594272bf91 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -53,6 +53,7 @@ import org.meshtastic.feature.map.component.MapFilterDropdown import org.meshtastic.feature.map.component.MapStyleSelector import org.meshtastic.feature.map.component.MaplibreMapContent import org.meshtastic.feature.map.model.MapStyle +import org.meshtastic.feature.map.util.computeBoundingBox import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.maplibre.spatialk.geojson.Position as GeoPosition @@ -205,6 +206,16 @@ fun MapScreen( onToggleWaypoints = viewModel::toggleShowWaypointsOnMap, onTogglePrecisionCircle = viewModel::toggleShowPrecisionCircleOnMap, onSetLastHeardFilter = viewModel::setLastHeardFilter, + onZoomToFitAll = { + val positions = + filteredNodes.mapNotNull { node -> + node.validPosition?.let { pos -> + toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) + } + } + val bbox = computeBoundingBox(positions) ?: return@MapFilterDropdown + scope.launch { cameraState.animateTo(bbox) } + }, ) }, mapTypeContent = { diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt index 53f3d5dc2f..5b8cb59935 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt @@ -39,10 +39,12 @@ import org.meshtastic.core.resources.last_heard_filter_label 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.resources.zoom_to_fit_all 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.PinDrop +import org.meshtastic.core.ui.icon.SelectAll import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.LastHeardFilter import kotlin.math.roundToInt @@ -62,6 +64,7 @@ internal fun MapFilterDropdown( onToggleWaypoints: () -> Unit, onTogglePrecisionCircle: () -> Unit, onSetLastHeardFilter: (LastHeardFilter) -> Unit, + onZoomToFitAll: () -> Unit, ) { DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { DropdownMenuItem( @@ -101,6 +104,20 @@ internal fun MapFilterDropdown( ) HorizontalDivider() LastHeardSlider(filterState.lastHeardFilter, onSetLastHeardFilter) + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(Res.string.zoom_to_fit_all)) }, + onClick = { + onZoomToFitAll() + onDismissRequest() + }, + leadingIcon = { + Icon( + imageVector = MeshtasticIcons.SelectAll, + contentDescription = stringResource(Res.string.zoom_to_fit_all), + ) + }, + ) } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 2f23615e64..63ad11aa72 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -28,15 +28,20 @@ import kotlinx.coroutines.launch import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraState import org.maplibre.compose.expressions.dsl.asString +import org.maplibre.compose.expressions.dsl.condition import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.convertToBoolean import org.maplibre.compose.expressions.dsl.convertToColor import org.maplibre.compose.expressions.dsl.convertToNumber import org.maplibre.compose.expressions.dsl.dp import org.maplibre.compose.expressions.dsl.exponential import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.format import org.maplibre.compose.expressions.dsl.interpolate import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.dsl.offset +import org.maplibre.compose.expressions.dsl.span +import org.maplibre.compose.expressions.dsl.switch import org.maplibre.compose.expressions.dsl.times import org.maplibre.compose.expressions.dsl.zoom import org.maplibre.compose.layers.CircleLayer @@ -69,6 +74,8 @@ import org.meshtastic.feature.map.util.waypointsToFeatureCollection import org.maplibre.spatialk.geojson.Position as GeoPosition private val NodeMarkerColor = Color(0xFF6750A4) +private val OnlineStrokeColor = Color(0xFF4CAF50) // Green — node heard within online threshold +private val OfflineStrokeColor = Color(0xFF9E9E9E) // Gray — node not heard recently private const val CLUSTER_RADIUS = 50 private const val CLUSTER_MIN_POINTS = 10 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f @@ -227,7 +234,7 @@ private fun NodeMarkerLayers( textSize = const(1.2f.em), ) - // Individual node markers with per-node background color + // Individual node markers with per-node background color and online-status stroke CircleLayer( id = "node-markers", source = nodesSource, @@ -235,7 +242,11 @@ private fun NodeMarkerLayers( radius = const(NODE_MARKER_RADIUS), color = feature["background_color"].convertToColor(const(NodeMarkerColor)), strokeWidth = const(MARKER_STROKE_WIDTH), - strokeColor = const(Color.White), + strokeColor = + switch( + condition(feature["is_online"].convertToBoolean(), const(OnlineStrokeColor)), + fallback = const(OfflineStrokeColor), + ), onClick = { features -> val nodeNum = features.firstOrNull()?.properties?.get("node_num")?.toString()?.toIntOrNull() if (nodeNum != null) { @@ -247,12 +258,23 @@ private fun NodeMarkerLayers( }, ) - // Short name labels below node markers + // Short name labels with online status dot below node markers SymbolLayer( id = "node-labels", source = nodesSource, filter = !feature.has("cluster"), - textField = feature["short_name"].asString(), + textField = + format( + span(feature["short_name"].asString()), + span( + const(" \u25CF"), // U+25CF Black Circle + textColor = + switch( + condition(feature["is_online"].convertToBoolean(), const(OnlineStrokeColor)), + fallback = const(OfflineStrokeColor), + ), + ), + ), textSize = const(0.9f.em), textOffset = offset(0f.em, LABEL_OFFSET_EM.em), textColor = const(Color.DarkGray), diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index e3ea84423c..e4e5afdd4c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -45,6 +45,8 @@ internal fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null) put("last_heard", node.lastHeard) put("is_favorite", node.isFavorite) put("is_my_node", node.num == myNodeNum) + put("is_online", node.isOnline) + put("battery_level", node.batteryLevel ?: -1) put("hops_away", node.hopsAway) put("via_mqtt", node.viaMqtt) put("snr", node.snr.toDouble()) diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt index 1b5502f80f..d52da3a8f0 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt @@ -18,8 +18,10 @@ package org.meshtastic.feature.map.util import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.Point +import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node +import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.Position import org.meshtastic.proto.User import org.meshtastic.proto.Waypoint @@ -92,6 +94,51 @@ class GeoJsonConvertersTest { assertEquals("false", props["is_my_node"].toString()) } + @Test + fun nodesToFeatureCollection_isOnline_offlineByDefault() { + // lastHeard defaults to 0 (epoch 1970), always older than the 2-hour online threshold + val node = Node(num = 1, position = Position(latitude_i = 400000000, longitude_i = -740000000)) + val result = nodesToFeatureCollection(listOf(node)) + val props = result.features.first().properties + assertEquals("false", props["is_online"].toString()) + } + + @Test + fun nodesToFeatureCollection_isOnline_trueWhenRecentlyHeard() { + val recentTimestamp = nowSeconds.toInt() + val node = + Node( + num = 1, + position = Position(latitude_i = 400000000, longitude_i = -740000000), + lastHeard = recentTimestamp, + ) + val result = nodesToFeatureCollection(listOf(node)) + val props = result.features.first().properties + assertEquals("true", props["is_online"].toString()) + } + + @Test + fun nodesToFeatureCollection_batteryLevel_withKnownBattery() { + val node = + Node( + num = 1, + position = Position(latitude_i = 400000000, longitude_i = -740000000), + deviceMetrics = DeviceMetrics(battery_level = 75), + ) + val result = nodesToFeatureCollection(listOf(node)) + val props = result.features.first().properties + assertEquals(75, props["battery_level"]?.toString()?.toIntOrNull()) + } + + @Test + fun nodesToFeatureCollection_batteryLevel_nullDefaultsToNegativeOne() { + // Default DeviceMetrics has null battery_level — should map to -1 sentinel + val node = Node(num = 1, position = Position(latitude_i = 400000000, longitude_i = -740000000)) + val result = nodesToFeatureCollection(listOf(node)) + val props = result.features.first().properties + assertEquals(-1, props["battery_level"]?.toString()?.toIntOrNull()) + } + @Test fun nodesToFeatureCollection_multipleNodes() { val nodes = From 18dac2eb098fa4839c768a0bbe0faa95b633790e Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 18 May 2026 12:06:10 -0500 Subject: [PATCH 15/23] fix(map): resolve rebase issues and detekt violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove phantom dd-sdk-android-compose dependency (not in version catalog) - Deduplicate strings.xml (29 duplicate entries from rebase conflicts) - Add missing waypoint_lock_to_my_node string resource - Fix license header year format (2025-2026 → 2026) - Rename past-tense lambda params to present tense (detekt) - Use rememberUpdatedState for lambdas in LaunchedEffect - Fix composable parameter ordering (non-defaults before modifier) - Cap cluster zoom increment at max zoom level (24) - Suppress ModifierMissing on OfflineMapContent expect/actual Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .skills/compose-ui/strings-index.txt | 13 ++++ androidApp/build.gradle.kts | 1 - .../composeResources/values/strings.xml | 60 ++++--------------- .../feature/map/OfflineManagerFactory.kt | 2 +- .../org/meshtastic/feature/map/MapScreen.kt | 8 ++- .../map/component/EditWaypointDialog.kt | 2 +- .../map/component/MapFilterDropdown.kt | 2 +- .../feature/map/component/MapStyleSelector.kt | 2 +- .../map/component/MaplibreMapContent.kt | 16 ++--- .../feature/map/component/NodeTrackLayers.kt | 6 +- .../feature/map/component/NodeTrackMap.kt | 8 +-- .../feature/map/component/TracerouteLayers.kt | 6 +- .../feature/map/component/TracerouteMap.kt | 4 +- .../node/metrics/PositionLogScreens.kt | 2 +- .../node/metrics/TracerouteMapScreen.kt | 2 +- 15 files changed, 59 insertions(+), 75 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index f6884c99d5..5a92c0e055 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -698,7 +698,12 @@ map_reporting_interval_seconds map_reporting_summary map_select_download_region map_start_download +map_style_dark +map_style_light +map_style_osm +map_style_road_map map_style_selection +map_style_terrain map_subDescription map_tile_download_estimate map_tile_source @@ -887,6 +892,13 @@ notifications_on_message_receipt now ntp_server number_of_records +### OFFLINE ### +offline_download +offline_download_visible_region +offline_downloaded_regions +offline_maps +offline_saves_tiles +offline_unnamed_region ok_to_mqtt okay oled_type @@ -1419,3 +1431,4 @@ wind_speed you zh_CN zh_TW +zoom_to_fit_all diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 81896d0102..e68e5c6cbd 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -263,7 +263,6 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.glance.preview) - googleImplementation(libs.dd.sdk.android.compose) googleImplementation(libs.dd.sdk.android.logs) googleImplementation(libs.dd.sdk.android.rum) googleImplementation(libs.dd.sdk.android.session.replay) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index aa8c1fdac8..9ea2aaf178 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -728,7 +728,12 @@ Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, long and short name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name. Select download region Start Download + Dark + Light + OpenStreetMap + Road Map Map style selection + Terrain bearing: %1$d° distance: %2$s Tile download estimate: Tile Source @@ -917,6 +922,13 @@ Now NTP server Number of records + + Download + Download visible region + Downloaded Regions + Offline Maps + Saves tiles for offline use + Unnamed Region Ok to MQTT OK OLED type @@ -1248,53 +1260,6 @@ RX Boosted Gain System Settings - Generate QR Code - NFC is disabled. Please enable it in system settings. - All - - Bluetooth - Configure Bluetooth Permissions - Discovery - Find and identify Meshtastic devices near you. - Configuration - Wirelessly manage your device settings and channels. - Map style selection - OpenStreetMap - Light - Terrain - Road Map - Dark - Offline Maps - Download - Download visible region - Saves tiles for offline use - Downloaded Regions - Unnamed Region - - Battery: %1$d% - Nodes: %1$d online / %2$d total - Uptime: %1$s - ChUtil: %1$s% | AirTX: %2$s% - Traffic: TX %1$d / RX %2$d (D: %3$d) - Relays: %1$d (Canceled: %2$d) - Diagnostics: %1$s - Noise %1$d dBm - Bad %1$d - Dropped %1$d - Heap - %1$d / %2$d - %1$s - Powered - Refresh - Updated - - - Add Network Layer - https://example.com/map.kml or .geojson - - Local MBTiles File - Add Local MBTiles File - TAK (ATAK) TAK Configuration Member Role @@ -1457,6 +1422,7 @@ Warning Delete waypoint? Edit waypoint + Lock to my node New waypoint Received waypoint: %1$s Weight diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt index 561b056817..26856b139e 100644 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/OfflineManagerFactory.kt @@ -54,7 +54,7 @@ import org.meshtastic.core.resources.offline_unnamed_region import org.meshtastic.core.ui.icon.CloudDownload import org.meshtastic.core.ui.icon.MeshtasticIcons -@Suppress("LongMethod") +@Suppress("LongMethod", "ModifierMissing") @Composable actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) { val offlineManager = rememberOfflineManager() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 594272bf91..8027020a1b 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * 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 @@ -72,8 +72,8 @@ private val MAP_OVERLAY_PADDING = 16.dp fun MapScreen( onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, - modifier: Modifier = Modifier, viewModel: MapViewModel, + modifier: Modifier = Modifier, waypointId: Int? = null, ) { val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle() @@ -163,7 +163,7 @@ fun MapScreen( modifier = Modifier.fillMaxSize(), gestureOptions = gestureOptions, styleState = styleState, - onCameraMoved = { position -> viewModel.saveCameraPosition(position) }, + onCameraMove = { position -> viewModel.saveCameraPosition(position) }, onWaypointClick = { wpId -> editingWaypointId = wpId longPressPosition = null @@ -235,10 +235,12 @@ fun MapScreen( // TrackBearing → TrackNorth bearingUpdate = BearingUpdate.ALWAYS_NORTH } + BearingUpdate.ALWAYS_NORTH -> { // TrackNorth → Off isLocationTrackingEnabled = false } + BearingUpdate.IGNORE -> { isLocationTrackingEnabled = false } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index 5a2b408e99..8578885c4e 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * 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 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt index 5b8cb59935..015fd47564 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * 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 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt index a50c9e2a7b..9d3993aab3 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * 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 diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 63ad11aa72..6a5b022ee3 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -121,10 +122,10 @@ fun MaplibreMapContent( modifier: Modifier = Modifier, gestureOptions: GestureOptions = GestureOptions.Standard, styleState: StyleState = rememberStyleState(), - onCameraMoved: (CameraPosition) -> Unit = {}, + onCameraMove: (CameraPosition) -> Unit = {}, onWaypointClick: (Int) -> Unit = {}, - onMapLoadFinished: () -> Unit = {}, - onMapLoadFailed: (String?) -> Unit = {}, + onMapLoad: () -> Unit = {}, + onMapLoadFail: (String?) -> Unit = {}, locationState: UserLocationState? = null, ) { MaplibreMap( @@ -137,8 +138,8 @@ fun MaplibreMapContent( onMapLongClick(position) ClickResult.Consume }, - onMapLoadFinished = onMapLoadFinished, - onMapLoadFailed = onMapLoadFailed, + onMapLoadFinished = onMapLoad, + onMapLoadFailed = onMapLoadFail, ) { // --- Terrain hillshade overlay --- if (showHillshade) { @@ -172,9 +173,10 @@ fun MaplibreMapContent( } // Persist camera position when it stops moving + val currentOnCameraMove = rememberUpdatedState(onCameraMove) LaunchedEffect(cameraState.isCameraMoving) { if (!cameraState.isCameraMoving) { - onCameraMoved(cameraState.position) + currentOnCameraMove.value(cameraState.position) } } } @@ -216,7 +218,7 @@ private fun NodeMarkerLayers( cameraState.animateTo( cameraState.position.copy( target = target, - zoom = cameraState.position.zoom + CLUSTER_ZOOM_INCREMENT, + zoom = minOf(cameraState.position.zoom + CLUSTER_ZOOM_INCREMENT, PRECISION_ZOOM_MAX.toDouble()), ), ) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt index fe38673c3c..b07d92a5b9 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt @@ -49,7 +49,7 @@ private const val SELECTED_OPACITY = 0.9f internal fun NodeTrackLayers( positions: List, selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, + onSelectPosition: ((Int) -> Unit)? = null, ) { if (positions.size < 2) return @@ -87,8 +87,8 @@ internal fun NodeTrackLayers( strokeColor = const(Color.White), onClick = { features -> val time = features.firstOrNull()?.properties?.get("time")?.jsonPrimitive?.content?.toIntOrNull() - if (time != null && onPositionSelected != null) { - onPositionSelected(time) + if (time != null && onSelectPosition != null) { + onSelectPosition(time) ClickResult.Consume } else { ClickResult.Pass diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index 78091b1fca..619245a9f3 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -39,8 +39,8 @@ private const val BOUNDS_PADDING_DP = 48 /** * Embeddable position-track map showing a polyline with markers for the given positions. * - * Supports synchronized selection: [selectedPositionTime] highlights the corresponding marker and [onPositionSelected] - * is called when a marker is tapped, passing the `Position.time` for the host screen to synchronize its card list. + * Supports synchronized selection: [selectedPositionTime] highlights the corresponding marker and [onSelectPosition] is + * called when a marker is tapped, passing the `Position.time` for the host screen to synchronize its card list. * * Replaces both the Google Maps and OSMDroid flavor-specific NodeTrackMap implementations. */ @@ -49,7 +49,7 @@ fun NodeTrackMap( positions: List, modifier: Modifier = Modifier, selectedPositionTime: Int? = null, - onPositionSelected: ((Int) -> Unit)? = null, + onSelectPosition: ((Int) -> Unit)? = null, ) { val geoPositions = remember(positions) { positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } } @@ -82,7 +82,7 @@ fun NodeTrackMap( NodeTrackLayers( positions = positions, selectedPositionTime = selectedPositionTime, - onPositionSelected = onPositionSelected, + onSelectPosition = onSelectPosition, ) } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index 9aa2826dc9..22e88f8b19 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -19,6 +19,7 @@ package org.meshtastic.feature.map.component import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em @@ -66,7 +67,7 @@ internal fun TracerouteLayers( overlay: TracerouteOverlay?, nodePositions: Map, nodes: Map, - onMappableCountChanged: (shown: Int, total: Int) -> Unit, + onMappableCountChange: (shown: Int, total: Int) -> Unit, ) { if (overlay == null) return @@ -81,7 +82,8 @@ internal fun TracerouteLayers( // Report mappable count via side effect (avoid state updates during composition) val mappableCount = routeData.hopFeatures.features.size val totalCount = overlay.relatedNodeNums.size - LaunchedEffect(mappableCount, totalCount) { onMappableCountChanged(mappableCount, totalCount) } + val currentOnMappableCountChange = rememberUpdatedState(onMappableCountChange) + LaunchedEffect(mappableCount, totalCount) { currentOnMappableCountChange.value(mappableCount, totalCount) } // Forward route line if (routeData.forwardLine.features.isNotEmpty()) { diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index 68a93726d7..f1523e757b 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -53,7 +53,7 @@ private const val BOUNDS_PADDING_DP = 64 fun TracerouteMap( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map, - onMappableCountChanged: (shown: Int, total: Int) -> Unit, + onMappableCountChange: (shown: Int, total: Int) -> Unit, modifier: Modifier = Modifier, nodes: Map = emptyMap(), ) { @@ -91,7 +91,7 @@ fun TracerouteMap( overlay = tracerouteOverlay, nodePositions = tracerouteNodePositions, nodes = nodes, - onMappableCountChanged = onMappableCountChanged, + onMappableCountChange = onMappableCountChange, ) } } 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 14061cc769..25ed836208 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 @@ -67,7 +67,7 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { positions = positions, modifier = modifier, selectedPositionTime = selectedTime, - onPositionSelected = { time -> onPointSelected(time.toDouble()) }, + onSelectPosition = { time -> onPointSelected(time.toDouble()) }, ) }, listPart = { modifier, selectedX, lazyListState, onCardClick -> diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index 83079acb39..2b49b4bdc2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -118,7 +118,7 @@ private fun TracerouteMapScaffold( TracerouteMap( tracerouteOverlay = overlay, tracerouteNodePositions = snapshotPositions, - onMappableCountChanged = { shown: Int, total: Int -> + onMappableCountChange = { shown: Int, total: Int -> tracerouteNodesShown = shown tracerouteNodesTotal = total }, From 653955406eac25acfd6e89979f69025c7ff48a55 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 18 May 2026 12:48:22 -0500 Subject: [PATCH 16/23] feat(map): add error/empty states, filter badge, and waypoint feedback - Wire onMapLoadFail to Snackbar error message - Add MapEmptyState overlay when no nodes have position data - Add active filter count badge on filter button (BadgedBox) - Show snackbar confirmation on waypoint send/delete - Add string resources: map_empty_state, map_load_error, map_showing_filtered, waypoint_sent, waypoint_deleted Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .skills/compose-ui/strings-index.txt | 7 +++ .../composeResources/values/strings.xml | 6 ++ .../org/meshtastic/feature/map/MapScreen.kt | 44 ++++++++++++++- .../map/component/MapControlsOverlay.kt | 27 +++++++-- .../feature/map/component/MapEmptyState.kt | 56 +++++++++++++++++++ 5 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapEmptyState.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 5a92c0e055..d5ab9272ae 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -685,8 +685,10 @@ map_clear_tiles map_download_complete map_download_errors map_download_region +map_empty_state map_filter map_layer_formats +map_load_error map_node_popup_details map_offline_manager map_purge_fail @@ -697,6 +699,7 @@ map_reporting_consent_text map_reporting_interval_seconds map_reporting_summary map_select_download_region +map_showing_filtered map_start_download map_style_dark map_style_light @@ -1375,10 +1378,14 @@ voltage wait_for_bluetooth_duration_seconds wake_on_tap_or_motion warning +### WAYPOINT ### waypoint_delete +waypoint_deleted waypoint_edit +waypoint_lock_to_my_node waypoint_new waypoint_received +waypoint_sent weight ### WIFI ### wifi_config diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 9ea2aaf178..bf32430bb4 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -715,8 +715,10 @@ Download complete! Download complete with %1$d errors Download Region + No nodes with position data Map Filter\n Map layers support .kml, .kmz, or GeoJSON formats. + Map failed to load %1$s<br>Last heard: %2$s<br>Last position: %3$s<br>Battery: %4$s Offline Manager SQL Cache purge failed, see logcat for details @@ -727,6 +729,7 @@ Map reporting interval (seconds) Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, long and short name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name. Select download region + Showing %1$d of %2$d nodes Start Download Dark Light @@ -1420,11 +1423,14 @@ Wait for Bluetooth duration Wake on tap or motion Warning + Delete waypoint? + Waypoint deleted Edit waypoint Lock to my node New waypoint Received waypoint: %1$s + Waypoint sent Weight WiFi Options diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 8027020a1b..c639f80f3c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -20,6 +20,8 @@ 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.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -46,9 +48,14 @@ import org.maplibre.compose.material3.ExpandingAttributionButton import org.maplibre.compose.style.rememberStyleState import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map +import org.meshtastic.core.resources.map_empty_state +import org.meshtastic.core.resources.map_load_error +import org.meshtastic.core.resources.waypoint_deleted +import org.meshtastic.core.resources.waypoint_sent import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.feature.map.component.EditWaypointDialog import org.meshtastic.feature.map.component.MapControlsOverlay +import org.meshtastic.feature.map.component.MapEmptyState import org.meshtastic.feature.map.component.MapFilterDropdown import org.meshtastic.feature.map.component.MapStyleSelector import org.meshtastic.feature.map.component.MaplibreMapContent @@ -79,6 +86,7 @@ fun MapScreen( val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle() val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() val filteredNodes by viewModel.filteredNodes.collectAsStateWithLifecycle() + val nodesWithPosition by viewModel.nodesWithPosition.collectAsStateWithLifecycle() val waypoints by viewModel.waypoints.collectAsStateWithLifecycle() val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle() val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle() @@ -88,6 +96,7 @@ fun MapScreen( val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition) val styleState = rememberStyleState() + val snackbarHostState = remember { SnackbarHostState() } var filterMenuExpanded by remember { mutableStateOf(false) } @@ -98,6 +107,21 @@ fun MapScreen( val scope = rememberCoroutineScope() + // Snackbar messages for map load error + val mapLoadErrorMsg = stringResource(Res.string.map_load_error) + val waypointSentMsg = stringResource(Res.string.waypoint_sent) + val waypointDeletedMsg = stringResource(Res.string.waypoint_deleted) + + // Active filter count for badge + val activeFilterCount = + remember(filterState) { + var count = 0 + if (filterState.onlyFavorites) count++ + if (!filterState.showWaypoints) count++ + if (filterState.lastHeardFilter != LastHeardFilter.Any) count++ + count + } + // Location tracking state: 3-mode cycling (Off → Track → TrackBearing → Off) var isLocationTrackingEnabled by remember { mutableStateOf(false) } var bearingUpdate by remember { mutableStateOf(BearingUpdate.TRACK_LOCATION) } @@ -132,6 +156,7 @@ fun MapScreen( @Suppress("ViewModelForwarding") Scaffold( modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { MainAppBar( title = stringResource(Res.string.map), @@ -170,8 +195,17 @@ fun MapScreen( showWaypointDialog = true }, locationState = if (locationAvailable) locationState else null, + onMapLoadFail = { _ -> scope.launch { snackbarHostState.showSnackbar(mapLoadErrorMsg) } }, ) + // Empty state when no nodes have positions + if (nodesWithPosition.isEmpty()) { + MapEmptyState( + message = stringResource(Res.string.map_empty_state), + modifier = Modifier.align(Alignment.Center), + ) + } + // Auto-pan camera when location tracking is enabled if (locationAvailable) { LocationTrackingEffect( @@ -197,6 +231,7 @@ fun MapScreen( bearing = cameraState.position.bearing.toFloat(), onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } }, followPhoneBearing = isLocationTrackingEnabled && bearingUpdate == BearingUpdate.TRACK_LOCATION, + activeFilterCount = activeFilterCount, filterDropdownContent = { MapFilterDropdown( expanded = filterMenuExpanded, @@ -286,8 +321,15 @@ fun MapScreen( existingWaypoint = editingWaypoint, position = longPressPosition, ) + scope.launch { snackbarHostState.showSnackbar(waypointSentMsg) } + }, + onDelete = + editingWaypoint?.let { wpt -> + { + viewModel.deleteWaypoint(wpt.id) + scope.launch { snackbarHostState.showSnackbar(waypointDeletedMsg) } + } }, - onDelete = editingWaypoint?.let { wpt -> { viewModel.deleteWaypoint(wpt.id) } }, initialName = editingWaypoint?.name ?: "", initialDescription = editingWaypoint?.description ?: "", initialIcon = editingWaypoint?.icon ?: 0, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index 1da6d1f26a..df644568a0 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -21,11 +21,15 @@ package org.meshtastic.feature.map.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingToolbarDefaults import androidx.compose.material3.HorizontalFloatingToolbar import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -70,6 +74,7 @@ fun MapControlsOverlay( bearing: Float = 0f, onCompassClick: () -> Unit = {}, followPhoneBearing: Boolean = false, + activeFilterCount: Int = 0, filterDropdownContent: @Composable () -> Unit = {}, mapTypeContent: @Composable () -> Unit = {}, layersContent: @Composable () -> Unit = {}, @@ -88,13 +93,23 @@ fun MapControlsOverlay( // Compass CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing) - // Filter button + dropdown + // Filter button + dropdown with badge Box { - MapButton( - icon = MeshtasticIcons.Tune, - contentDescription = stringResource(Res.string.map_filter), - onClick = onToggleFilterMenu, - ) + if (activeFilterCount > 0) { + BadgedBox(badge = { Badge { Text(activeFilterCount.toString()) } }) { + MapButton( + icon = MeshtasticIcons.Tune, + contentDescription = stringResource(Res.string.map_filter), + onClick = onToggleFilterMenu, + ) + } + } else { + MapButton( + icon = MeshtasticIcons.Tune, + contentDescription = stringResource(Res.string.map_filter), + onClick = onToggleFilterMenu, + ) + } filterDropdownContent() } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapEmptyState.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapEmptyState.kt new file mode 100644 index 0000000000..388562c1c1 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapEmptyState.kt @@ -0,0 +1,56 @@ +/* + * 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.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.meshtastic.core.ui.icon.LocationDisabled +import org.meshtastic.core.ui.icon.MeshtasticIcons + +/** Centered empty state overlay shown when no nodes have position data. */ +@Composable +internal fun MapEmptyState(message: String, modifier: Modifier = Modifier) { + Surface(modifier = modifier.padding(24.dp), shape = MaterialTheme.shapes.medium, tonalElevation = 2.dp) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = MeshtasticIcons.LocationDisabled, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} From 305ff2321fe0e50a1c2361116931f3c77ae9174e Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 18 May 2026 13:00:43 -0500 Subject: [PATCH 17/23] feat(map): add line gradient, zoom buttons, camera padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NodeTrackLayers: replace flat color with lineProgress() gradient (faded blue → vivid blue showing position age) - MapControlsOverlay: add +/- zoom buttons in secondary toolbar (improves desktop/accessibility where pinch isn't natural) - MapScreen: add padding to zoom-to-fit-all camera animation to avoid UI controls overlap - Add lineProgress() expression helper (line-progress MapLibre expr) - Add MeshtasticIcons.Remove (minus) icon Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .skills/compose-ui/strings-index.txt | 2 + .../composeResources/drawable/ic_remove.xml | 9 ++ .../composeResources/values/strings.xml | 2 + .../org/meshtastic/core/ui/icon/Actions.kt | 3 + .../org/meshtastic/feature/map/MapScreen.kt | 19 ++- .../map/component/MapControlsOverlay.kt | 119 +++++++++++------- .../feature/map/component/NodeTrackLayers.kt | 10 +- .../feature/map/util/MapConstants.kt | 10 ++ 8 files changed, 121 insertions(+), 53 deletions(-) create mode 100644 core/resources/src/commonMain/composeResources/drawable/ic_remove.xml diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index d5ab9272ae..482263a259 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -714,6 +714,8 @@ map_type_hybrid map_type_normal map_type_satellite map_type_terrain +map_zoom_in +map_zoom_out mark_as_read match_all match_any diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_remove.xml b/core/resources/src/commonMain/composeResources/drawable/ic_remove.xml new file mode 100644 index 0000000000..089b549919 --- /dev/null +++ b/core/resources/src/commonMain/composeResources/drawable/ic_remove.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index bf32430bb4..641530db58 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -744,6 +744,8 @@ Normal Satellite Terrain + Zoom in + Zoom out Mark as read Match All | Any Match Any | All diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt index 9da4588c65..c025b7ccb2 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt @@ -46,6 +46,7 @@ import org.meshtastic.core.resources.ic_qr_code import org.meshtastic.core.resources.ic_qr_code_2 import org.meshtastic.core.resources.ic_qr_code_scanner import org.meshtastic.core.resources.ic_refresh +import org.meshtastic.core.resources.ic_remove import org.meshtastic.core.resources.ic_reply import org.meshtastic.core.resources.ic_restart_alt import org.meshtastic.core.resources.ic_restore @@ -136,3 +137,5 @@ val MeshtasticIcons.BarChart: ImageVector @Composable get() = vectorResource(Res.drawable.ic_bar_chart) val MeshtasticIcons.List: ImageVector @Composable get() = vectorResource(Res.drawable.ic_list) +val MeshtasticIcons.Remove: ImageVector + @Composable get() = vectorResource(Res.drawable.ic_remove) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index c639f80f3c..def0b1cc00 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.map import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold @@ -65,6 +66,8 @@ import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.maplibre.spatialk.geojson.Position as GeoPosition private const val WAYPOINT_ZOOM = 15.0 +private const val MIN_ZOOM = 0.0 +private const val MAX_ZOOM = 24.0 private val MAP_OVERLAY_PADDING = 16.dp /** @@ -249,7 +252,7 @@ fun MapScreen( } } val bbox = computeBoundingBox(positions) ?: return@MapFilterDropdown - scope.launch { cameraState.animateTo(bbox) } + scope.launch { cameraState.animateTo(bbox, padding = PaddingValues(48.dp)) } }, ) }, @@ -282,6 +285,20 @@ fun MapScreen( } } }, + onZoomIn = { + scope.launch { + cameraState.animateTo( + cameraState.position.copy(zoom = minOf(cameraState.position.zoom + 1.0, MAX_ZOOM)), + ) + } + }, + onZoomOut = { + scope.launch { + cameraState.animateTo( + cameraState.position.copy(zoom = maxOf(cameraState.position.zoom - 1.0, MIN_ZOOM)), + ) + } + }, ) // Scale bar — auto-shows on zoom change, hides after 3 seconds diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index df644568a0..8e505c05e9 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -19,33 +19,38 @@ package org.meshtastic.feature.map.component import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingToolbarDefaults import androidx.compose.material3.HorizontalFloatingToolbar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map_filter +import org.meshtastic.core.resources.map_zoom_in +import org.meshtastic.core.resources.map_zoom_out 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.Add import org.meshtastic.core.ui.icon.LocationOn import org.meshtastic.core.ui.icon.MapCompass import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MyLocation import org.meshtastic.core.ui.icon.NearMe import org.meshtastic.core.ui.icon.Refresh +import org.meshtastic.core.ui.icon.Remove import org.meshtastic.core.ui.icon.Tune import org.meshtastic.core.ui.theme.StatusColors.StatusRed import kotlin.math.abs @@ -66,7 +71,7 @@ import kotlin.math.abs * @param onRefresh Callback when the refresh button is clicked. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LongMethod") @Composable fun MapControlsOverlay( onToggleFilterMenu: () -> Unit, @@ -84,68 +89,86 @@ fun MapControlsOverlay( showRefresh: Boolean = false, isRefreshing: Boolean = false, onRefresh: () -> Unit = {}, + onZoomIn: () -> Unit = {}, + onZoomOut: () -> Unit = {}, ) { - HorizontalFloatingToolbar( - expanded = true, - modifier = modifier, - colors = FloatingToolbarDefaults.standardFloatingToolbarColors(), - ) { - // Compass - CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing) + Column(modifier = modifier, horizontalAlignment = Alignment.End) { + HorizontalFloatingToolbar(expanded = true, colors = FloatingToolbarDefaults.standardFloatingToolbarColors()) { + // Compass + CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing) - // Filter button + dropdown with badge - Box { - if (activeFilterCount > 0) { - BadgedBox(badge = { Badge { Text(activeFilterCount.toString()) } }) { + // Filter button + dropdown with badge + Box { + if (activeFilterCount > 0) { + BadgedBox(badge = { Badge { Text(activeFilterCount.toString()) } }) { + MapButton( + icon = MeshtasticIcons.Tune, + contentDescription = stringResource(Res.string.map_filter), + onClick = onToggleFilterMenu, + ) + } + } else { MapButton( icon = MeshtasticIcons.Tune, contentDescription = stringResource(Res.string.map_filter), onClick = onToggleFilterMenu, ) } - } else { - MapButton( - icon = MeshtasticIcons.Tune, - contentDescription = stringResource(Res.string.map_filter), - onClick = onToggleFilterMenu, - ) + filterDropdownContent() } - filterDropdownContent() - } - // Map type selector (flavor-specific) - mapTypeContent() + // Map type selector (flavor-specific) + mapTypeContent() - // Layers button (flavor-specific) - layersContent() + // Layers button (flavor-specific) + layersContent() - // Refresh button (optional) - if (showRefresh) { - if (isRefreshing) { - Box(modifier = Modifier.padding(8.dp)) { - CircularWavyProgressIndicator(modifier = Modifier.size(24.dp)) + // Refresh button (optional) + if (showRefresh) { + if (isRefreshing) { + Box(modifier = Modifier.padding(8.dp)) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } + } else { + MapButton( + icon = MeshtasticIcons.Refresh, + contentDescription = stringResource(Res.string.refresh), + onClick = onRefresh, + ) } - } else { - MapButton( - icon = MeshtasticIcons.Refresh, - contentDescription = stringResource(Res.string.refresh), - onClick = onRefresh, - ) } + + // Location tracking button — 3 states: Off (MyLocation), Tracking (NearMe), TrackingNorth (LocationOn) + MapButton( + icon = + when { + !isLocationTrackingEnabled -> MeshtasticIcons.MyLocation + isTrackingBearing -> MeshtasticIcons.NearMe + else -> MeshtasticIcons.LocationOn + }, + contentDescription = stringResource(Res.string.toggle_my_position), + iconTint = if (isLocationTrackingEnabled) MaterialTheme.colorScheme.primary else null, + onClick = onToggleLocationTracking, + ) } - // Location tracking button — 3 states: Off (MyLocation), Tracking (NearMe), TrackingNorth (LocationOn) - MapButton( - icon = - when { - !isLocationTrackingEnabled -> MeshtasticIcons.MyLocation - isTrackingBearing -> MeshtasticIcons.NearMe - else -> MeshtasticIcons.LocationOn - }, - contentDescription = stringResource(Res.string.toggle_my_position), - iconTint = if (isLocationTrackingEnabled) MaterialTheme.colorScheme.primary else null, - onClick = onToggleLocationTracking, - ) + // Zoom buttons (useful for desktop/accessibility where pinch isn't natural) + HorizontalFloatingToolbar( + expanded = true, + modifier = Modifier.padding(top = 4.dp), + colors = FloatingToolbarDefaults.standardFloatingToolbarColors(), + ) { + MapButton( + icon = MeshtasticIcons.Add, + contentDescription = stringResource(Res.string.map_zoom_in), + onClick = onZoomIn, + ) + MapButton( + icon = MeshtasticIcons.Remove, + contentDescription = stringResource(Res.string.map_zoom_out), + onClick = onZoomOut, + ) + } } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt index b07d92a5b9..efb2804b67 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt @@ -25,6 +25,8 @@ import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.eq import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.interpolate +import org.maplibre.compose.expressions.dsl.linear import org.maplibre.compose.expressions.value.LineCap import org.maplibre.compose.expressions.value.LineJoin import org.maplibre.compose.layers.CircleLayer @@ -33,12 +35,13 @@ import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonOptions import org.maplibre.compose.sources.rememberGeoJsonSource import org.maplibre.compose.util.ClickResult +import org.meshtastic.feature.map.util.lineProgress import org.meshtastic.feature.map.util.positionsToLineString import org.meshtastic.feature.map.util.positionsToPointFeatures private val TrackColor = Color(0xFF2196F3) +private val TrackColorFaded = Color(0x662196F3) private val SelectedPointColor = Color(0xFFF44336) -private const val TRACK_OPACITY = 0.8f private const val SELECTED_OPACITY = 0.9f /** @@ -62,13 +65,12 @@ internal fun NodeTrackLayers( options = GeoJsonOptions(lineMetrics = true), ) - // Track line with gradient + // Track line with gradient (oldest positions faded → newest positions vivid) LineLayer( id = "node-track-line", source = lineSource, width = const(3.dp), - color = const(TrackColor), // Blue - opacity = const(TRACK_OPACITY), + gradient = interpolate(linear(), lineProgress(), 0 to const(TrackColorFaded), 1 to const(TrackColor)), cap = const(LineCap.Round), join = const(LineJoin.Round), ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt index df78d4b3cb..839162631c 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/MapConstants.kt @@ -18,6 +18,9 @@ package org.meshtastic.feature.map.util import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.maplibre.compose.expressions.ast.Expression +import org.maplibre.compose.expressions.ast.FunctionCall +import org.maplibre.compose.expressions.value.FloatValue import org.maplibre.spatialk.geojson.BoundingBox import org.maplibre.spatialk.geojson.Position as GeoPosition @@ -57,3 +60,10 @@ internal fun computeBoundingBox(positions: List): BoundingBox? { northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()), ) } + +/** + * Gets the progress along a line feature, from 0 at the start to 1 at the end. Can only be used with GeoJSON sources + * that specify `lineMetrics = true`. Use with [interpolate][org.maplibre.compose.expressions.dsl.interpolate] to create + * gradient colors. + */ +internal fun lineProgress(): Expression = FunctionCall.of("line-progress").cast() From 4607c3f223b07c78d3b7e58ea46f4bb9d67b04c9 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 18 May 2026 13:09:02 -0500 Subject: [PATCH 18/23] feat(map): inline map polish & location permission feedback - InlineMap: add short name SymbolLayer label above marker - InlineMap: adaptive zoom (zoom out for imprecise positions >500m) - MapScreen: show snackbar when tapping location button without permission instead of silently doing nothing - Add map_location_unavailable string resource Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .skills/compose-ui/strings-index.txt | 1 + .../composeResources/values/strings.xml | 1 + .../org/meshtastic/feature/map/MapScreen.kt | 6 +++- .../feature/map/component/InlineMap.kt | 28 +++++++++++++++++-- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 482263a259..61da77ce06 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -689,6 +689,7 @@ map_empty_state map_filter map_layer_formats map_load_error +map_location_unavailable map_node_popup_details map_offline_manager map_purge_fail diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 641530db58..2ad87a94bb 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -719,6 +719,7 @@ Map Filter\n Map layers support .kml, .kmz, or GeoJSON formats. Map failed to load + Location permission required for tracking %1$s<br>Last heard: %2$s<br>Last position: %3$s<br>Battery: %4$s Offline Manager SQL Cache purge failed, see logcat for details diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index def0b1cc00..a8a20eabcc 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -51,6 +51,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map import org.meshtastic.core.resources.map_empty_state import org.meshtastic.core.resources.map_load_error +import org.meshtastic.core.resources.map_location_unavailable import org.meshtastic.core.resources.waypoint_deleted import org.meshtastic.core.resources.waypoint_sent import org.meshtastic.core.ui.component.MainAppBar @@ -112,6 +113,7 @@ fun MapScreen( // Snackbar messages for map load error val mapLoadErrorMsg = stringResource(Res.string.map_load_error) + val locationUnavailableMsg = stringResource(Res.string.map_location_unavailable) val waypointSentMsg = stringResource(Res.string.waypoint_sent) val waypointDeletedMsg = stringResource(Res.string.waypoint_deleted) @@ -263,7 +265,9 @@ fun MapScreen( isLocationTrackingEnabled = isLocationTrackingEnabled, isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION, onToggleLocationTracking = { - if (!isLocationTrackingEnabled) { + if (!locationAvailable) { + scope.launch { snackbarHostState.showSnackbar(locationUnavailableMsg) } + } else if (!isLocationTrackingEnabled) { // Off → Track with bearing bearingUpdate = BearingUpdate.TRACK_LOCATION isLocationTrackingEnabled = true diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 8f555895d1..922af53089 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -22,10 +22,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.offset +import org.maplibre.compose.expressions.value.SymbolAnchor import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap @@ -44,7 +48,10 @@ import org.meshtastic.feature.map.util.precisionBitsToMeters import org.meshtastic.feature.map.util.toGeoPositionOrNull private const val DEFAULT_ZOOM = 15.0 +private const val LOW_PRECISION_ZOOM = 12.0 +private const val PRECISION_THRESHOLD_METERS = 500 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f +private const val LABEL_OFFSET = -2f /** * A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the @@ -55,8 +62,12 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { val position = node.validPosition ?: return val geoPos = toGeoPositionOrNull(position.latitude_i, position.longitude_i) ?: return + // Adaptive zoom: zoom out for imprecise positions so the precision circle is visible + val precisionMeters = precisionBitsToMeters(position.precision_bits) + val zoom = if (precisionMeters > PRECISION_THRESHOLD_METERS) LOW_PRECISION_ZOOM else DEFAULT_ZOOM + key(node.num) { - val cameraState = rememberCameraState(firstPosition = CameraPosition(target = geoPos, zoom = DEFAULT_ZOOM)) + val cameraState = rememberCameraState(firstPosition = CameraPosition(target = geoPos, zoom = zoom)) val nodeFeature = remember(node.num, geoPos) { @@ -82,8 +93,21 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { strokeColor = const(Color.White), ) + // Short name label above the marker + val shortName = node.user.short_name + if (!shortName.isNullOrBlank()) { + SymbolLayer( + id = "inline-node-label", + source = source, + textField = const(shortName).cast(), + textSize = const(0.9f.em), + textOffset = offset(0f.em, LABEL_OFFSET.em), + textAnchor = const(SymbolAnchor.Bottom), + textColor = const(Color.DarkGray), + ) + } + // Precision circle — radius computed from precision_meters using latitude-aware metersPerDp - val precisionMeters = precisionBitsToMeters(position.precision_bits) val metersPerDp = cameraState.metersPerDpAtTarget if (precisionMeters > 0 && metersPerDp > 0) { val radiusDp = (precisionMeters / metersPerDp).dp From 883bb6ae48fc1b90f05b9d78c7d3b05a243f8108 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 18 May 2026 13:14:16 -0500 Subject: [PATCH 19/23] feat(map): add node info bottom sheet on map tap Show a compact bottom sheet with node name, last heard, battery, and signal info when tapping a node marker. Users can tap 'View Details' to navigate to the full node detail screen, preserving map context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .skills/compose-ui/strings-index.txt | 1 + .../composeResources/values/strings.xml | 1 + .../org/meshtastic/feature/map/MapScreen.kt | 19 +++- .../feature/map/component/NodeInfoSheet.kt | 91 +++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 61da77ce06..b464549fba 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1375,6 +1375,7 @@ uv_lux via_api via_mqtt via_udp +view_details view_on_map view_release voltage diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 2ad87a94bb..cbee045a27 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1420,6 +1420,7 @@ via API via MQTT via UDP + View Details View on map View Release Voltage diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index a8a20eabcc..42d0f6c2e6 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -61,6 +61,7 @@ import org.meshtastic.feature.map.component.MapEmptyState import org.meshtastic.feature.map.component.MapFilterDropdown import org.meshtastic.feature.map.component.MapStyleSelector import org.meshtastic.feature.map.component.MaplibreMapContent +import org.meshtastic.feature.map.component.NodeInfoSheet import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.feature.map.util.computeBoundingBox import org.meshtastic.feature.map.util.toGeoPositionOrNull @@ -104,6 +105,9 @@ fun MapScreen( var filterMenuExpanded by remember { mutableStateOf(false) } + // Node info sheet state + var selectedNodeNum by remember { mutableStateOf(null) } + // Waypoint dialog state var showWaypointDialog by remember { mutableStateOf(false) } var longPressPosition by remember { mutableStateOf(null) } @@ -184,7 +188,7 @@ fun MapScreen( showWaypoints = filterState.showWaypoints, showPrecisionCircle = filterState.showPrecisionCircle, showHillshade = selectedMapStyle == MapStyle.Terrain, - onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) }, + onNodeClick = { nodeNum -> selectedNodeNum = nodeNum }, onMapLongClick = { position -> longPressPosition = position editingWaypointId = null @@ -359,4 +363,17 @@ fun MapScreen( position = longPressPosition, ) } + + // Node info bottom sheet + val selectedNode = selectedNodeNum?.let { num -> filteredNodes.find { it.num == num } } + if (selectedNode != null) { + NodeInfoSheet( + node = selectedNode, + onDismiss = { selectedNodeNum = null }, + onViewDetails = { nodeNum -> + selectedNodeNum = null + navigateToNodeDetails(nodeNum) + }, + ) + } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt new file mode 100644 index 0000000000..c9880067f1 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt @@ -0,0 +1,91 @@ +/* + * 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.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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.view_details +import org.meshtastic.core.ui.component.LastHeardInfo +import org.meshtastic.core.ui.component.MaterialBatteryInfo +import org.meshtastic.core.ui.component.SignalInfo + +/** + * A modal bottom sheet showing a compact summary of a node when tapped on the map. Provides quick info (name, last + * heard, battery, signal) and a button to navigate to full details. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun NodeInfoSheet(node: Node, onDismiss: () -> Unit, onViewDetails: (Int) -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp)) { + // Node name + Text( + text = node.user.long_name.ifBlank { node.user.short_name }, + style = MaterialTheme.typography.titleLarge, + ) + if (node.user.long_name.isNotBlank() && node.user.short_name.isNotBlank()) { + Text( + text = node.user.short_name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Info row: last heard, battery, signal + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LastHeardInfo(lastHeard = node.lastHeard, showLabel = false) + MaterialBatteryInfo(level = node.batteryLevel) + SignalInfo(node = node) + } + + Spacer(Modifier.height(24.dp)) + + // View details button + Button(onClick = { onViewDetails(node.num) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(Res.string.view_details)) + } + + Spacer(Modifier.height(8.dp)) + } + } +} From 34ec760412476fd25e2aafb5db2d096035658aee Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 18 May 2026 13:52:43 -0500 Subject: [PATCH 20/23] fix(map): align with Meshtastic design standards and M3 best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded hex colors with MaterialTheme.colorScheme tokens (primary, onPrimary, onSurfaceVariant, surface) so map layers respect light/dark mode transitions - Add TooltipBox with PlainTooltip to MapButton for desktop hover accessibility (design standard §4: tooltips for icon-only buttons) - Set explicit containerColor and scrimColor on NodeInfoSheet's ModalBottomSheet for M3 compliance - Import MaterialTheme in MaplibreMapContent, TracerouteLayers, and InlineMap to read semantic color tokens in composable scope Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../feature/map/component/InlineMap.kt | 5 +++- .../feature/map/component/MapButton.kt | 28 ++++++++++++++----- .../map/component/MaplibreMapContent.kt | 21 ++++++++------ .../feature/map/component/NodeInfoSheet.kt | 7 ++++- .../feature/map/component/TracerouteLayers.kt | 13 +++++---- 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 922af53089..5e4683823e 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.map.component +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.runtime.remember @@ -62,6 +63,8 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { val position = node.validPosition ?: return val geoPos = toGeoPositionOrNull(position.latitude_i, position.longitude_i) ?: return + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant + // Adaptive zoom: zoom out for imprecise positions so the precision circle is visible val precisionMeters = precisionBitsToMeters(position.precision_bits) val zoom = if (precisionMeters > PRECISION_THRESHOLD_METERS) LOW_PRECISION_ZOOM else DEFAULT_ZOOM @@ -103,7 +106,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { textSize = const(0.9f.em), textOffset = offset(0f.em, LABEL_OFFSET.em), textAnchor = const(SymbolAnchor.Bottom), - textColor = const(Color.DarkGray), + textColor = const(labelColor), ) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt index 26bd8d5ba6..c0473afb3d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt @@ -16,15 +16,23 @@ */ package org.meshtastic.feature.map.component +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable 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. */ +/** A compact icon button used in map control overlays. Uses [FilledIconButton] with a hover tooltip for desktop. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MapButton( icon: ImageVector, @@ -33,11 +41,17 @@ internal fun MapButton( modifier: Modifier = Modifier, iconTint: Color? = null, ) { - FilledIconButton(onClick = onClick, modifier = modifier) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor, - ) + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text(contentDescription) } }, + state = rememberTooltipState(), + ) { + FilledIconButton(onClick = onClick, modifier = modifier) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor, + ) + } } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 6a5b022ee3..fb6f4b85fb 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.map.component +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -74,7 +75,6 @@ import org.meshtastic.feature.map.util.nodesToFeatureCollection import org.meshtastic.feature.map.util.waypointsToFeatureCollection import org.maplibre.spatialk.geojson.Position as GeoPosition -private val NodeMarkerColor = Color(0xFF6750A4) private val OnlineStrokeColor = Color(0xFF4CAF50) // Green — node heard within online threshold private val OfflineStrokeColor = Color(0xFF9E9E9E) // Gray — node not heard recently private const val CLUSTER_RADIUS = 50 @@ -194,6 +194,11 @@ private fun NodeMarkerLayers( val coroutineScope = rememberCoroutineScope() val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) } + // Read M3 semantic colors for map layers (recomposes on theme change) + val clusterColor = MaterialTheme.colorScheme.primary + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant + val clusterLabelColor = MaterialTheme.colorScheme.onPrimary + val nodesSource = rememberGeoJsonSource( data = GeoJsonData.Features(featureCollection), @@ -207,10 +212,10 @@ private fun NodeMarkerLayers( source = nodesSource, filter = feature.has("cluster"), radius = const(20.dp), - color = const(NodeMarkerColor), // Material primary + color = const(clusterColor), opacity = const(CLUSTER_OPACITY), strokeWidth = const(MARKER_STROKE_WIDTH), - strokeColor = const(Color.White), + strokeColor = const(clusterLabelColor), onClick = { features -> val cluster = features.firstOrNull() ?: return@CircleLayer ClickResult.Pass val target = (cluster.geometry as? Point)?.coordinates ?: return@CircleLayer ClickResult.Pass @@ -232,7 +237,7 @@ private fun NodeMarkerLayers( source = nodesSource, filter = feature.has("cluster"), textField = feature["point_count"].asString(), - textColor = const(Color.White), + textColor = const(clusterLabelColor), textSize = const(1.2f.em), ) @@ -242,7 +247,7 @@ private fun NodeMarkerLayers( source = nodesSource, filter = !feature.has("cluster"), radius = const(NODE_MARKER_RADIUS), - color = feature["background_color"].convertToColor(const(NodeMarkerColor)), + color = feature["background_color"].convertToColor(const(clusterColor)), strokeWidth = const(MARKER_STROKE_WIDTH), strokeColor = switch( @@ -279,7 +284,7 @@ private fun NodeMarkerLayers( ), textSize = const(0.9f.em), textOffset = offset(0f.em, LABEL_OFFSET_EM.em), - textColor = const(Color.DarkGray), + textColor = const(labelColor), textAllowOverlap = const(true), iconAllowOverlap = const(true), ) @@ -301,13 +306,13 @@ private fun NodeMarkerLayers( radius = (feature["precision_meters"].convertToNumber(const(0f)) * metersToPixels).dp, color = feature["background_color"].convertToColor( - const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), + const(clusterColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), ), opacity = const(PRECISION_CIRCLE_FILL_ALPHA), strokeWidth = const(1.dp), strokeColor = feature["background_color"].convertToColor( - const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)), + const(clusterColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)), ), strokeOpacity = const(PRECISION_CIRCLE_STROKE_ALPHA), ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt index c9880067f1..d8d6ba4535 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt @@ -50,7 +50,12 @@ import org.meshtastic.core.ui.component.SignalInfo internal fun NodeInfoSheet(node: Node, onDismiss: () -> Unit, onViewDetails: (Int) -> Unit) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + scrimColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f), + ) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp)) { // Node name Text( diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index 22e88f8b19..e1662fb265 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.map.component +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -52,9 +53,9 @@ import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.feature.map.util.typedFeatureCollection import org.maplibre.spatialk.geojson.Position as GeoPosition -private val ForwardRouteColor = Color(0xFF4CAF50) -private val ReturnRouteColor = Color(0xFFF44336) -private val HopMarkerColor = Color(0xFF9C27B0) +private val ForwardRouteColor = Color(0xFF4CAF50) // Success green — forward path +private val ReturnRouteColor = Color(0xFFF44336) // Error red — return path +private val HopMarkerColor = Color(0xFF9C27B0) // Tertiary purple — hop points private const val HEX_RADIX = 16 private const val ROUTE_OPACITY = 0.8f @@ -72,6 +73,8 @@ internal fun TracerouteLayers( if (overlay == null) return val unknownNodeName = stringResource(Res.string.unknown) + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant + val markerStrokeColor = MaterialTheme.colorScheme.surface // Build route line features val routeData = @@ -123,7 +126,7 @@ internal fun TracerouteLayers( radius = const(NODE_MARKER_RADIUS), color = const(HopMarkerColor), // Purple strokeWidth = const(MARKER_STROKE_WIDTH), - strokeColor = const(Color.White), + strokeColor = const(markerStrokeColor), ) SymbolLayer( id = "traceroute-hop-labels", @@ -131,7 +134,7 @@ internal fun TracerouteLayers( textField = feature["short_name"].asString(), textSize = const(1.em), textOffset = offset(0f.em, -2f.em), - textColor = const(Color.DarkGray), + textColor = const(labelColor), ) } } From 1e6a4235eb1c26393ab4fc9d64ce4982dd3b6859 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 18 May 2026 14:43:57 -0500 Subject: [PATCH 21/23] feat(map): add pulsing online indicator and satellite tile style - Add animated pulsing ring behind online nodes using Compose InfiniteTransition (expanding radius + fading opacity) - Add Satellite map style using free Esri World Imagery raster tiles - Use BaseStyle.Json for inline raster style definition - Derive baseStyle from selectedMapStyle (single source of truth) - Update MapStyleTest to verify both Uri and Json style variants - Update MapViewModelTest to use toBaseStyle() assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .skills/compose-ui/strings-index.txt | 1 + .../composeResources/values/strings.xml | 1 + .../meshtastic/feature/map/MapViewModel.kt | 10 +++--- .../map/component/MaplibreMapContent.kt | 32 +++++++++++++++++++ .../meshtastic/feature/map/model/MapStyle.kt | 23 +++++++++++-- .../feature/map/MapViewModelTest.kt | 7 ++-- .../feature/map/model/MapStyleTest.kt | 11 +++++-- 7 files changed, 71 insertions(+), 14 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index b464549fba..5bd1021666 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -706,6 +706,7 @@ map_style_dark map_style_light map_style_osm map_style_road_map +map_style_satellite map_style_selection map_style_terrain map_subDescription diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index cbee045a27..58d7eace41 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -736,6 +736,7 @@ Light OpenStreetMap Road Map + Satellite Map style selection Terrain bearing: %1$d° distance: %2$s diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 616d953b05..b9b55d0dc8 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -73,18 +73,16 @@ class MapViewModel( bearing = mapCameraPrefs.cameraBearing.value.toDouble(), ) - /** Active map base style. */ - val baseStyle: StateFlow = - mapCameraPrefs.selectedStyleUri - .map { uri -> if (uri.isBlank()) MapStyle.OpenStreetMap.toBaseStyle() else BaseStyle.Uri(uri) } - .stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle()) - /** Currently selected map style enum index. */ val selectedMapStyle: StateFlow = mapCameraPrefs.selectedStyleUri .map { uri -> MapStyle.entries.find { it.styleUri == uri } ?: MapStyle.OpenStreetMap } .stateInWhileSubscribed(MapStyle.OpenStreetMap) + /** Active map base style derived from the selected [MapStyle]. */ + val baseStyle: StateFlow = + selectedMapStyle.map { it.toBaseStyle() }.stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle()) + /** Persist camera position to DataStore. */ fun saveCameraPosition(position: CameraPosition) { mapCameraPrefs.setCameraLat(position.target.latitude) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index fb6f4b85fb..aae47f8989 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -16,9 +16,16 @@ */ package org.meshtastic.feature.map.component +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -97,6 +104,9 @@ private const val CLUSTER_OPACITY = 0.85f private const val LABEL_OFFSET_EM = 1.5f private const val CLUSTER_ZOOM_INCREMENT = 2.0 private const val HILLSHADE_EXAGGERATION = 0.5f +private const val PULSE_DURATION_MS = 1500 +private const val PULSE_MAX_RADIUS_DP = 14f +private const val PULSE_START_OPACITY = 0.5f /** Free Terrain Tiles (Terrarium encoding) hosted on AWS. No API key required. */ private val TERRAIN_TILES = listOf("https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png") @@ -199,6 +209,16 @@ private fun NodeMarkerLayers( val labelColor = MaterialTheme.colorScheme.onSurfaceVariant val clusterLabelColor = MaterialTheme.colorScheme.onPrimary + // Pulsing ring animation for online nodes + val pulseTransition = rememberInfiniteTransition(label = "node-pulse") + val pulseProgress by + pulseTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(PULSE_DURATION_MS, easing = LinearEasing), RepeatMode.Restart), + label = "pulse-progress", + ) + val nodesSource = rememberGeoJsonSource( data = GeoJsonData.Features(featureCollection), @@ -241,6 +261,18 @@ private fun NodeMarkerLayers( textSize = const(1.2f.em), ) + // Pulsing ring behind online nodes — animated radius expanding outward with fading opacity + val pulseRadius = (NODE_MARKER_RADIUS.value + (PULSE_MAX_RADIUS_DP - NODE_MARKER_RADIUS.value) * pulseProgress).dp + val pulseOpacity = PULSE_START_OPACITY * (1f - pulseProgress) + CircleLayer( + id = "node-pulse-ring", + source = nodesSource, + filter = feature["is_online"].convertToBoolean(), + radius = const(pulseRadius), + color = const(OnlineStrokeColor), + opacity = const(pulseOpacity), + ) + // Individual node markers with per-node background color and online-status stroke CircleLayer( id = "node-markers", diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt index 339b69e507..3cf5a2adde 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt @@ -23,12 +23,13 @@ import org.meshtastic.core.resources.map_style_dark import org.meshtastic.core.resources.map_style_light import org.meshtastic.core.resources.map_style_osm import org.meshtastic.core.resources.map_style_road_map +import org.meshtastic.core.resources.map_style_satellite import org.meshtastic.core.resources.map_style_terrain /** * Predefined map tile styles available in the app. * - * Uses free tile sources that do not require API keys. All styles are vector-based and work across platforms. + * Uses free tile sources that do not require API keys. */ enum class MapStyle(val label: StringResource, val styleUri: String) { /** OpenStreetMap default tiles via OpenFreeMap Liberty style. */ @@ -45,7 +46,25 @@ enum class MapStyle(val label: StringResource, val styleUri: String) { /** Dark mode style via OpenFreeMap Fiord. */ Dark(label = Res.string.map_style_dark, styleUri = "https://tiles.openfreemap.org/styles/fiord"), + + /** Satellite imagery via Esri World Imagery (free for non-commercial use). */ + Satellite(label = Res.string.map_style_satellite, styleUri = SATELLITE_STYLE_URI), ; - fun toBaseStyle(): BaseStyle = BaseStyle.Uri(styleUri) + fun toBaseStyle(): BaseStyle = when (this) { + Satellite -> BaseStyle.Json(SATELLITE_STYLE_JSON) + else -> BaseStyle.Uri(styleUri) + } } + +/** Stable URI used as persistence key for satellite style selection. */ +private const val SATELLITE_STYLE_URI = "satellite://esri-world-imagery" + +/** + * Inline MapLibre style JSON for raster satellite imagery. + * + * Uses Esri World Imagery tiles which are free for non-commercial and educational use. + */ +@Suppress("MaxLineLength") +private const val SATELLITE_STYLE_JSON: String = + """{"version":8,"name":"Satellite","sources":{"esri-satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":18,"attribution":"Esri, Maxar, Earthstar Geographics"}},"layers":[{"id":"satellite","type":"raster","source":"esri-satellite"}]}""" diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt index 12321cc524..3b73c3736b 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import org.maplibre.compose.style.BaseStyle import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.testing.FakeMapCameraPrefs import org.meshtastic.core.testing.FakeMapPrefs @@ -141,7 +140,7 @@ class MapViewModelTest { fun baseStyleDefaultsToOpenStreetMap() = runTest(testDispatcher) { viewModel.baseStyle.test { val style = awaitItem() - assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style) + assertEquals(MapStyle.OpenStreetMap.toBaseStyle(), style) cancelAndIgnoreRemainingEvents() } } @@ -166,7 +165,7 @@ class MapViewModelTest { viewModel.selectMapStyle(MapStyle.Dark) val darkStyle = awaitItem() - assertEquals(BaseStyle.Uri(MapStyle.Dark.styleUri), darkStyle) + assertEquals(MapStyle.Dark.toBaseStyle(), darkStyle) cancelAndIgnoreRemainingEvents() } @@ -177,7 +176,7 @@ class MapViewModelTest { // selectedStyleUri defaults to "" in FakeMapCameraPrefs viewModel.baseStyle.test { val style = awaitItem() - assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style) + assertEquals(MapStyle.OpenStreetMap.toBaseStyle(), style) cancelAndIgnoreRemainingEvents() } } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt index e630097658..a4e2ed728e 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt @@ -25,14 +25,21 @@ import kotlin.test.assertTrue class MapStyleTest { @Test - fun toBaseStyle_returnsUriWithCorrectStyleUri() { - for (style in MapStyle.entries) { + fun toBaseStyle_returnsUriForVectorStyles() { + for (style in MapStyle.entries.filter { it != MapStyle.Satellite }) { val baseStyle = style.toBaseStyle() assertIs(baseStyle) assertEquals(style.styleUri, baseStyle.uri) } } + @Test + fun toBaseStyle_returnsJsonForSatellite() { + val baseStyle = MapStyle.Satellite.toBaseStyle() + assertIs(baseStyle) + assertTrue(baseStyle.json.contains("esri-satellite")) + } + @Test fun allStyles_haveNonBlankUri() { for (style in MapStyle.entries) { From b7cdc26a6e2b9076f73a20983a8ecf3c5287a784 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 18 May 2026 14:58:43 -0500 Subject: [PATCH 22/23] fix(map): pulse indicator fires on new packet, not permanent online status Change the pulsing ring from showing for all online nodes to only nodes heard within the last 5 seconds. This correctly indicates when a new packet arrives rather than acting as a static online badge. - Add 'recently_heard' boolean property to GeoJSON features - Use Clock.System.now().epochSeconds with periodic tick (1s) to expire stale pulse states - Filter pulse layer on 'recently_heard' instead of 'is_online' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../map/component/MaplibreMapContent.kt | 25 ++++++++++++++++--- .../feature/map/util/GeoJsonConverters.kt | 12 ++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index aae47f8989..fadaa46f51 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -26,14 +26,18 @@ import androidx.compose.material3.MaterialTheme 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.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.datetime.Clock import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraState import org.maplibre.compose.expressions.dsl.asString @@ -107,6 +111,8 @@ private const val HILLSHADE_EXAGGERATION = 0.5f private const val PULSE_DURATION_MS = 1500 private const val PULSE_MAX_RADIUS_DP = 14f private const val PULSE_START_OPACITY = 0.5f +private const val PULSE_WINDOW_SECONDS = 5L +private const val PULSE_TICK_INTERVAL_MS = 1000L /** Free Terrain Tiles (Terrarium encoding) hosted on AWS. No API key required. */ private val TERRAIN_TILES = listOf("https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png") @@ -202,14 +208,25 @@ private fun NodeMarkerLayers( onNodeClick: (Int) -> Unit, ) { val coroutineScope = rememberCoroutineScope() - val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) } + + // Tick current time to expire pulse animations after PULSE_WINDOW_SECONDS + var nowEpochSeconds by remember { mutableStateOf(Clock.System.now().epochSeconds) } + LaunchedEffect(Unit) { + while (true) { + delay(PULSE_TICK_INTERVAL_MS) + nowEpochSeconds = Clock.System.now().epochSeconds + } + } + + val featureCollection = + remember(nodes, myNodeNum, nowEpochSeconds) { nodesToFeatureCollection(nodes, myNodeNum, nowEpochSeconds) } // Read M3 semantic colors for map layers (recomposes on theme change) val clusterColor = MaterialTheme.colorScheme.primary val labelColor = MaterialTheme.colorScheme.onSurfaceVariant val clusterLabelColor = MaterialTheme.colorScheme.onPrimary - // Pulsing ring animation for online nodes + // Pulsing ring animation for recently-heard nodes val pulseTransition = rememberInfiniteTransition(label = "node-pulse") val pulseProgress by pulseTransition.animateFloat( @@ -261,13 +278,13 @@ private fun NodeMarkerLayers( textSize = const(1.2f.em), ) - // Pulsing ring behind online nodes — animated radius expanding outward with fading opacity + // Pulsing ring behind recently-heard nodes — indicates new packet received val pulseRadius = (NODE_MARKER_RADIUS.value + (PULSE_MAX_RADIUS_DP - NODE_MARKER_RADIUS.value) * pulseProgress).dp val pulseOpacity = PULSE_START_OPACITY * (1f - pulseProgress) CircleLayer( id = "node-pulse-ring", source = nodesSource, - filter = feature["is_online"].convertToBoolean(), + filter = feature["recently_heard"].convertToBoolean(), radius = const(pulseRadius), color = const(OnlineStrokeColor), opacity = const(pulseOpacity), diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt index e4e5afdd4c..d0d780e4b7 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt @@ -29,15 +29,24 @@ import org.meshtastic.core.model.Node private const val MIN_PRECISION_BITS = 10 private const val MAX_PRECISION_BITS = 19 +private const val RECENTLY_HEARD_WINDOW_SECONDS = 5 /** Convert a list of nodes to a GeoJSON [FeatureCollection] for map rendering. */ -internal fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): FeatureCollection { +internal fun nodesToFeatureCollection( + nodes: List, + myNodeNum: Int? = null, + nowEpochSeconds: Long = 0L, +): FeatureCollection { val features = nodes.mapNotNull { node -> val pos = node.validPosition ?: return@mapNotNull null val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null val colors = node.colors + val recentlyHeard = + nowEpochSeconds > 0L && + node.lastHeard > 0 && + (nowEpochSeconds - node.lastHeard) <= RECENTLY_HEARD_WINDOW_SECONDS val props = buildJsonObject { put("node_num", node.num) put("short_name", node.user.short_name) @@ -46,6 +55,7 @@ internal fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null) put("is_favorite", node.isFavorite) put("is_my_node", node.num == myNodeNum) put("is_online", node.isOnline) + put("recently_heard", recentlyHeard) put("battery_level", node.batteryLevel ?: -1) put("hops_away", node.hopsAway) put("via_mqtt", node.viaMqtt) From aed1c4228616180bcc40b82eb757fb483ac90e93 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 2 Jun 2026 13:30:52 -0500 Subject: [PATCH 23/23] chore(map): bump MapLibre Compose to 0.13.0, light up desktop base map, drop Google Maps leftovers Rebased onto main and dusted off the MapLibre Compose Multiplatform branch. - Bump maplibre-compose 0.12.1 -> 0.13.0 and migrate the redesigned location API (BearingUpdate.TRACK_LOCATION -> TRACK_AUTOMATIC; LocationPuck locationState -> location). - desktopApp: add a host-detecting maplibre-native-bindings-jni runtime backend so the desktop base map renders (Metal on macOS arm64, OpenGL on Linux/Windows amd64). Without it Gradle links no native renderer and the map canvas is black. - Gate Compose map overlays off on the JVM target via a mapOverlaysSupported expect/actual flag (true Android/iOS, false desktop). maplibre-compose 0.13.0 stubs the desktop layers/sources API with TODO(), so composing markers/waypoints/tracks/traceroute threw NotImplementedError and tore down the window. Desktop now renders base-map-only; overlays auto-enable when upstream implements desktop layers. - Remove orphaned Google Maps leftovers the rebase carried over from main's #5702/#5709 marker work (MarkerBitmapRenderer.kt + play-services-maps); this branch replaces Google Maps entirely. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/map/component/MarkerBitmapRenderer.kt | 144 ------------------ desktopApp/build.gradle.kts | 35 +++++ .../feature/map/MapOverlaysSupport.kt | 20 +++ .../feature/map/MapOverlaysSupport.kt | 31 ++++ .../org/meshtastic/feature/map/MapScreen.kt | 27 ++-- .../feature/map/component/InlineMap.kt | 5 + .../map/component/MaplibreMapContent.kt | 7 +- .../feature/map/component/NodeTrackMap.kt | 4 + .../feature/map/component/TracerouteMap.kt | 4 + .../feature/map/MapOverlaysSupport.kt | 20 +++ .../feature/map/MapOverlaysSupport.kt | 23 +++ gradle/libs.versions.toml | 9 +- 12 files changed, 167 insertions(+), 162 deletions(-) delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt create mode 100644 feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt create mode 100644 feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt deleted file mode 100644 index 9b0c161eb8..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt +++ /dev/null @@ -1,144 +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.component - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.RectF -import android.graphics.Typeface -import android.text.TextPaint -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.core.graphics.createBitmap -import com.google.android.gms.maps.MapsInitializer -import com.google.android.gms.maps.model.BitmapDescriptor -import com.google.android.gms.maps.model.BitmapDescriptorFactory -import org.meshtastic.core.model.Node - -private const val CHIP_CORNER_RADIUS_DP = 4f -private const val CHIP_PADDING_HORIZONTAL_DP = 8f -private const val CHIP_MIN_WIDTH_DP = 64f -private const val CHIP_MIN_HEIGHT_DP = 28f -private const val CHIP_TEXT_SIZE_SP = 14f -private const val EMOJI_TEXT_SIZE_SP = 32f -private const val EMOJI_PADDING_DP = 2f - -/** - * Renders a node chip marker as a [BitmapDescriptor] using Canvas — avoids the off-screen ComposeView pipeline in - * maps-compose's `MarkerComposable`/`rememberComposeBitmapDescriptor` which can crash with "The ComposeView was - * measured to have a width or height of zero" during subcomposition races (googlemaps/android-maps-compose#875). - */ -@Composable -fun rememberNodeChipDescriptor(node: Node): BitmapDescriptor { - val context = LocalContext.current - val density = LocalDensity.current.density - val fontScale = LocalDensity.current.fontScale - return remember(node.num, node.user.short_name, node.colors, node.isIgnored) { - ensureMapsInitialized(context) - renderNodeChipBitmap(node, density, fontScale) - } -} - -/** Renders an emoji waypoint marker as a [BitmapDescriptor] using Canvas. */ -@Composable -fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor { - val context = LocalContext.current - val density = LocalDensity.current.density - val fontScale = LocalDensity.current.fontScale - return remember(codePoint) { - ensureMapsInitialized(context) - renderEmojiBitmap(codePoint, density, fontScale) - } -} - -/** - * [BitmapDescriptorFactory] only works after the Maps SDK has been initialized, which normally happens when a - * GoogleMap/MapView is created. These descriptors are built during composition, and on the node-detail inline map the - * icon is computed before that screen's GoogleMap has loaded the SDK — so [BitmapDescriptorFactory.fromBitmap] crashes - * with "IBitmapDescriptorFactory is not initialized". Initialize explicitly first; [MapsInitializer.initialize] is - * synchronous and idempotent, so it is a no-op once the SDK is already up. - */ -@Suppress("DEPRECATION") -private fun ensureMapsInitialized(context: Context) { - MapsInitializer.initialize(context) -} - -private fun renderNodeChipBitmap(node: Node, density: Float, fontScale: Float): BitmapDescriptor { - val (textColorInt, nodeColorInt) = node.colors - val scaledDensity = density * fontScale - - val textPaint = - TextPaint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = CHIP_TEXT_SIZE_SP * scaledDensity - typeface = Typeface.DEFAULT_BOLD - color = textColorInt - textAlign = Paint.Align.CENTER - isStrikeThruText = node.isIgnored - } - val label = node.user.short_name.ifEmpty { "???" } - - val textWidth = textPaint.measureText(label) - val paddingH = CHIP_PADDING_HORIZONTAL_DP * density - val minWidth = CHIP_MIN_WIDTH_DP * density - val minHeight = CHIP_MIN_HEIGHT_DP * density - - val width = maxOf(minWidth, textWidth + paddingH * 2).toInt() - val height = minHeight.toInt() - - val bitmap = createBitmap(width, height) - val canvas = Canvas(bitmap) - - val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = nodeColorInt } - val cornerRadius = CHIP_CORNER_RADIUS_DP * density - canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), cornerRadius, cornerRadius, bgPaint) - - val textX = width / 2f - val textY = (height / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f) - canvas.drawText(label, textX, textY, textPaint) - - return BitmapDescriptorFactory.fromBitmap(bitmap) -} - -private fun renderEmojiBitmap(codePoint: Int, density: Float, fontScale: Float): BitmapDescriptor { - val scaledDensity = density * fontScale - val textPaint = - TextPaint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = EMOJI_TEXT_SIZE_SP * scaledDensity - textAlign = Paint.Align.CENTER - } - val emoji = String(Character.toChars(codePoint)) - val padding = EMOJI_PADDING_DP * density - - val textWidth = textPaint.measureText(emoji) - val metrics = textPaint.fontMetrics - val textHeight = metrics.descent - metrics.ascent - - val width = (textWidth + padding * 2).toInt().coerceAtLeast(1) - val height = (textHeight + padding * 2).toInt().coerceAtLeast(1) - - val bitmap = createBitmap(width, height) - val canvas = Canvas(bitmap) - - val textX = width / 2f - val textY = padding - metrics.ascent - canvas.drawText(emoji, textX, textY, textPaint) - - return BitmapDescriptorFactory.fromBitmap(bitmap) -} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index d684fafaef..c54a322059 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -233,7 +233,42 @@ compose.desktop { } } +/** + * Selects the MapLibre Native desktop backend capability matching the build host. + * + * MapLibre Compose ships the native renderer as mutually-exclusive per-OS/arch capability variants of + * `maplibre-native-bindings-jni`. Gradle can't auto-pick among them, so without an explicit selection no native library + * is linked and the map canvas renders black. See https://maplibre.org/maplibre-compose/getting-started/. + * + * macOS is Apple-Silicon only (Metal); Linux/Windows use OpenGL here for broadest driver/Flatpak/CI compatibility + * (Vulkan variants — `*-amd64-vulkan` — are also published if preferred). + */ +fun maplibreNativeTarget(): String { + val os = System.getProperty("os.name").lowercase() + val arch = System.getProperty("os.arch").lowercase() + return when { + os.contains("mac") || os.contains("darwin") -> { + require(arch == "aarch64" || arch == "arm64") { + "MapLibre Native desktop ships only a macOS arm64 (Apple Silicon) backend; host arch '$arch' is unsupported." + } + "macos-aarch64-metal" + } + + os.contains("win") -> "windows-amd64-opengl" + + else -> "linux-amd64-opengl" + } +} + dependencies { + // MapLibre Native renderer for the current desktop host (runtime-only). Selects exactly one OS/arch + // capability — required, or the map renders black. See maplibreNativeTarget() above. + runtimeOnly("org.maplibre.compose:maplibre-native-bindings-jni:${libs.versions.maplibre.compose.get()}") { + capabilities { + requireCapability("org.maplibre.compose:maplibre-native-bindings-jni-${maplibreNativeTarget()}") + } + } + implementation(libs.aboutlibraries.core) implementation(libs.aboutlibraries.compose.m3) diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt new file mode 100644 index 0000000000..c1f6cc6a02 --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt @@ -0,0 +1,20 @@ +/* + * 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 + +/** Android implements the full MapLibre Compose sources/layers API. */ +actual val mapOverlaysSupported: Boolean = true diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt new file mode 100644 index 0000000000..dc57f4959d --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt @@ -0,0 +1,31 @@ +/* + * 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 + +/** + * Whether the current platform's MapLibre Compose target implements programmatic map sources and layers + * (`rememberGeoJsonSource`, `CircleLayer`, `SymbolLayer`, `LineLayer`, `HillshadeLayer`, …). + * + * As of maplibre-compose 0.13.0 the desktop (JVM) target stubs the **entire** sources/layers API with `TODO()`, so + * composing any overlay throws `NotImplementedError` ("An operation is not implemented") and tears down the window. The + * base map style still renders natively from its style URI. Every source/layer composition must therefore be guarded + * behind this flag; when `false`, only the base map is shown. + * + * Re-evaluate on each maplibre-compose upgrade — once the desktop target implements layers/sources, set this `true` for + * JVM and the guards become no-ops. + */ +expect val mapOverlaysSupported: Boolean diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 42d0f6c2e6..dd104fe584 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -133,7 +133,7 @@ fun MapScreen( // Location tracking state: 3-mode cycling (Off → Track → TrackBearing → Off) var isLocationTrackingEnabled by remember { mutableStateOf(false) } - var bearingUpdate by remember { mutableStateOf(BearingUpdate.TRACK_LOCATION) } + var bearingUpdate by remember { mutableStateOf(BearingUpdate.TRACK_AUTOMATIC) } val locationProvider = rememberLocationProviderOrNull() val locationState = rememberUserLocationState(locationProvider ?: rememberNullLocationProvider()) val locationAvailable = locationProvider != null @@ -144,8 +144,13 @@ fun MapScreen( if (isLocationTrackingEnabled) { when (bearingUpdate) { BearingUpdate.IGNORE -> GestureOptions.PositionLocked + BearingUpdate.ALWAYS_NORTH -> GestureOptions.ZoomOnly - BearingUpdate.TRACK_LOCATION -> GestureOptions.ZoomOnly + + BearingUpdate.TRACK_AUTOMATIC, + BearingUpdate.TRACK_COURSE, + BearingUpdate.TRACK_ORIENTATION, + -> GestureOptions.ZoomOnly } } else { GestureOptions.Standard @@ -220,7 +225,7 @@ fun MapScreen( LocationTrackingEffect( locationState = locationState, enabled = isLocationTrackingEnabled, - trackBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION, + trackBearing = bearingUpdate == BearingUpdate.TRACK_AUTOMATIC, ) { cameraState.updateFromLocation(updateBearing = bearingUpdate) } @@ -239,7 +244,7 @@ fun MapScreen( modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues), bearing = cameraState.position.bearing.toFloat(), onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } }, - followPhoneBearing = isLocationTrackingEnabled && bearingUpdate == BearingUpdate.TRACK_LOCATION, + followPhoneBearing = isLocationTrackingEnabled && bearingUpdate == BearingUpdate.TRACK_AUTOMATIC, activeFilterCount = activeFilterCount, filterDropdownContent = { MapFilterDropdown( @@ -267,27 +272,23 @@ fun MapScreen( }, layersContent = { OfflineMapContent(styleUri = selectedMapStyle.styleUri, cameraState = cameraState) }, isLocationTrackingEnabled = isLocationTrackingEnabled, - isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION, + isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_AUTOMATIC, onToggleLocationTracking = { if (!locationAvailable) { scope.launch { snackbarHostState.showSnackbar(locationUnavailableMsg) } } else if (!isLocationTrackingEnabled) { // Off → Track with bearing - bearingUpdate = BearingUpdate.TRACK_LOCATION + bearingUpdate = BearingUpdate.TRACK_AUTOMATIC isLocationTrackingEnabled = true } else { when (bearingUpdate) { - BearingUpdate.TRACK_LOCATION -> { + BearingUpdate.TRACK_AUTOMATIC -> { // TrackBearing → TrackNorth bearingUpdate = BearingUpdate.ALWAYS_NORTH } - BearingUpdate.ALWAYS_NORTH -> { - // TrackNorth → Off - isLocationTrackingEnabled = false - } - - BearingUpdate.IGNORE -> { + else -> { + // TrackNorth (or any other) → Off isLocationTrackingEnabled = false } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 5e4683823e..541bd6ec78 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -41,6 +41,7 @@ import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.Node +import org.meshtastic.feature.map.mapOverlaysSupported import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS @@ -84,6 +85,10 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { options = MapOptions(gestureOptions = GestureOptions.AllDisabled, ornamentOptions = OrnamentOptions.AllDisabled), ) { + // Desktop (maplibre-compose 0.13.0) stubs all layers/sources; render base map only. See + // [mapOverlaysSupported]. + if (!mapOverlaysSupported) return@MaplibreMap + val source = rememberGeoJsonSource(data = GeoJsonData.Features(nodeFeature)) // Node marker dot diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index fadaa46f51..887e3003dd 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -79,6 +79,7 @@ import org.maplibre.compose.util.ClickResult import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node +import org.meshtastic.feature.map.mapOverlaysSupported import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS import org.meshtastic.feature.map.util.PRECISION_CIRCLE_STROKE_ALPHA @@ -157,6 +158,10 @@ fun MaplibreMapContent( onMapLoadFinished = onMapLoad, onMapLoadFailed = onMapLoadFail, ) { + // MapLibre Compose layers/sources are stubbed on desktop (maplibre-compose 0.13.0); gate overlays off + // there so the base map still renders without throwing NotImplementedError. See [mapOverlaysSupported]. + if (!mapOverlaysSupported) return@MaplibreMap + // --- Terrain hillshade overlay --- if (showHillshade) { val demSource = rememberRasterDemSource(tiles = TERRAIN_TILES, encoding = RasterDemEncoding.Terrarium) @@ -181,7 +186,7 @@ fun MaplibreMapContent( if (locationState != null) { LocationPuck( idPrefix = "user-location", - locationState = locationState, + location = locationState.location, cameraState = cameraState, colors = LocationPuckDefaults.colors(), ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt index 619245a9f3..0d2a42f237 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt @@ -28,6 +28,7 @@ import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions +import org.meshtastic.feature.map.mapOverlaysSupported import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.feature.map.util.computeBoundingBox import org.meshtastic.feature.map.util.toGeoPositionOrNull @@ -79,6 +80,9 @@ fun NodeTrackMap( options = MapOptions(gestureOptions = GestureOptions.RotationLocked, ornamentOptions = OrnamentOptions.AllEnabled), ) { + // Desktop (maplibre-compose 0.13.0) stubs all layers/sources; render base map only. See [mapOverlaysSupported]. + if (!mapOverlaysSupported) return@MaplibreMap + NodeTrackLayers( positions = positions, selectedPositionTime = selectedPositionTime, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt index f1523e757b..064bc33293 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt @@ -30,6 +30,7 @@ import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.feature.map.mapOverlaysSupported import org.meshtastic.feature.map.model.MapStyle import org.meshtastic.feature.map.util.computeBoundingBox import org.meshtastic.feature.map.util.toGeoPositionOrNull @@ -87,6 +88,9 @@ fun TracerouteMap( options = MapOptions(gestureOptions = GestureOptions.RotationLocked, ornamentOptions = OrnamentOptions.AllEnabled), ) { + // Desktop (maplibre-compose 0.13.0) stubs all layers/sources; render base map only. See [mapOverlaysSupported]. + if (!mapOverlaysSupported) return@MaplibreMap + TracerouteLayers( overlay = tracerouteOverlay, nodePositions = tracerouteNodePositions, diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt new file mode 100644 index 0000000000..2dfb87c2ea --- /dev/null +++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt @@ -0,0 +1,20 @@ +/* + * 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 + +/** iOS implements the full MapLibre Compose sources/layers API. */ +actual val mapOverlaysSupported: Boolean = true diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt new file mode 100644 index 0000000000..4e1df79af6 --- /dev/null +++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/MapOverlaysSupport.kt @@ -0,0 +1,23 @@ +/* + * 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 + +/** + * Desktop (JVM) does NOT implement the MapLibre Compose sources/layers API in maplibre-compose 0.13.0 — every + * `Layer`/`Source` is stubbed with `TODO()`. Overlays are gated off so the base map renders without crashing. + */ +actual val mapOverlaysSupported: Boolean = false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 55c4eb56ef..b5d2d11e6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,14 +47,15 @@ compose-multiplatform-material3 = "1.11.0-alpha07" # AndroidCompose.kt's resolutionStrategy force-aligns these groups to *this* version # at resolution time, so it is the source of truth for the Android target. androidx-compose-bom-aligned = "1.11.2" -# `androidx-compose-material` (M2) is independent of CMP. Pinned because -# maps-compose-widgets requests `androidx.compose.material:material` without -# a version (relying on a BOM that we exclude). M2 is frozen at 1.7.8. +# `androidx-compose-material` (M2) is independent of CMP. Pinned because some +# transitive consumers request `androidx.compose.material:material` without a +# version (relying on a BOM that we exclude). M2 is frozen at 1.7.8. +# Consumed by build-logic AndroidCompose.kt to force-align the M2 material group. androidx-compose-material = "1.7.8" jetbrains-adaptive = "1.3.0-beta01" # MapLibre -maplibre-compose = "0.12.1" +maplibre-compose = "0.13.0" # ML Kit mlkit-barcode-scanning = "17.3.0"