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 d98dc681a..f26228c79 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 000000000..9b23f7003
--- /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 d610935e0..f322b2ccb 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 000000000..ee130055f
--- /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 431354e6d..656e01f13 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
@@ -38,6 +38,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
@@ -70,6 +71,7 @@ fun MapControlsOverlay(
mapTypeContent: @Composable () -> Unit = {},
layersContent: @Composable () -> Unit = {},
isLocationTrackingEnabled: Boolean = false,
+ isTrackingBearing: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
showRefresh: Boolean = false,
isRefreshing: Boolean = false,
@@ -114,10 +116,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 f749d3a89..dae42ed61 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 94341d419..a4c8fdd5d 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 043772898..c919b2afa 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 9cf0fe133..7dbb9b029 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 21f063433..334521d68 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 000000000..819cf1708
--- /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 000000000..bda1f9aa1
--- /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.
+}