From fc1deee409c01cb390645e3cfcbfdb582f6ed723 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 12 Apr 2026 20:52:08 -0500 Subject: [PATCH] =?UTF-8?q?feat(map):=20add=20maplibre-compose=20API=20enh?= =?UTF-8?q?ancements=20=E2=80=94=20scale=20bar,=20bearing=20tracking,=20ge?= =?UTF-8?q?stures,=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 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. +}