feat(map): add maplibre-compose API enhancements — scale bar, bearing tracking, gestures, hillshade, offline tiles, map styles

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
This commit is contained in:
James Rich
2026-04-12 20:52:08 -05:00
parent d71a8a3ea9
commit fc1deee409
12 changed files with 453 additions and 19 deletions

View File

@@ -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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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")
}
}

View File

@@ -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
}
}
}
},
)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)

View File

@@ -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,
)
}

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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")
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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.
}