mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-14 02:49:56 -04:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
Reference in New Issue
Block a user