Skip to content

Commit fc1deee

Browse files
committed
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
1 parent d71a8a3 commit fc1deee

12 files changed

Lines changed: 453 additions & 19 deletions

File tree

feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,23 @@
1616
*/
1717
package org.meshtastic.feature.map
1818

19+
import android.Manifest
1920
import androidx.compose.runtime.Composable
21+
import com.google.accompanist.permissions.ExperimentalPermissionsApi
22+
import com.google.accompanist.permissions.rememberMultiplePermissionsState
2023
import org.maplibre.compose.location.LocationProvider
2124
import org.maplibre.compose.location.rememberDefaultLocationProvider
2225

23-
@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = rememberDefaultLocationProvider()
26+
@OptIn(ExperimentalPermissionsApi::class)
27+
@Composable
28+
actual fun rememberLocationProviderOrNull(): LocationProvider? {
29+
val locationPermissions =
30+
rememberMultiplePermissionsState(
31+
permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
32+
)
33+
return if (locationPermissions.allPermissionsGranted) {
34+
rememberDefaultLocationProvider()
35+
} else {
36+
null
37+
}
38+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.map
18+
19+
import androidx.compose.foundation.clickable
20+
import androidx.compose.foundation.layout.Column
21+
import androidx.compose.foundation.layout.Row
22+
import androidx.compose.foundation.layout.fillMaxWidth
23+
import androidx.compose.foundation.layout.padding
24+
import androidx.compose.material3.AlertDialog
25+
import androidx.compose.material3.Icon
26+
import androidx.compose.material3.IconButton
27+
import androidx.compose.material3.MaterialTheme
28+
import androidx.compose.material3.Text
29+
import androidx.compose.material3.TextButton
30+
import androidx.compose.runtime.Composable
31+
import androidx.compose.runtime.getValue
32+
import androidx.compose.runtime.key
33+
import androidx.compose.runtime.mutableStateOf
34+
import androidx.compose.runtime.remember
35+
import androidx.compose.runtime.rememberCoroutineScope
36+
import androidx.compose.runtime.setValue
37+
import androidx.compose.ui.Alignment
38+
import androidx.compose.ui.Modifier
39+
import androidx.compose.ui.unit.dp
40+
import kotlinx.coroutines.launch
41+
import org.maplibre.compose.camera.CameraState
42+
import org.maplibre.compose.material3.OfflinePackListItem
43+
import org.maplibre.compose.offline.OfflinePackDefinition
44+
import org.maplibre.compose.offline.rememberOfflineManager
45+
import org.meshtastic.core.ui.icon.CloudDownload
46+
import org.meshtastic.core.ui.icon.MeshtasticIcons
47+
48+
@Composable actual fun isOfflineManagerAvailable(): Boolean = true
49+
50+
@Suppress("LongMethod")
51+
@Composable
52+
actual fun OfflineMapContent(styleUri: String, cameraState: CameraState) {
53+
val offlineManager = rememberOfflineManager()
54+
val coroutineScope = rememberCoroutineScope()
55+
var showDialog by remember { mutableStateOf(false) }
56+
57+
if (showDialog) {
58+
AlertDialog(
59+
onDismissRequest = { showDialog = false },
60+
title = { Text("Offline Maps") },
61+
text = {
62+
Column(modifier = Modifier.fillMaxWidth()) {
63+
// Download button for current viewport
64+
Row(
65+
verticalAlignment = Alignment.CenterVertically,
66+
modifier =
67+
Modifier.fillMaxWidth()
68+
.clickable {
69+
coroutineScope.launch {
70+
val projection = cameraState.awaitProjection()
71+
val bounds = projection.queryVisibleBoundingBox()
72+
val pack =
73+
offlineManager.create(
74+
definition =
75+
OfflinePackDefinition.TilePyramid(
76+
styleUrl = styleUri,
77+
bounds = bounds,
78+
),
79+
metadata = "Region".encodeToByteArray(),
80+
)
81+
offlineManager.resume(pack)
82+
}
83+
}
84+
.padding(vertical = 12.dp),
85+
) {
86+
Icon(
87+
imageVector = MeshtasticIcons.CloudDownload,
88+
contentDescription = "Download",
89+
modifier = Modifier.padding(end = 16.dp),
90+
)
91+
Column {
92+
Text(text = "Download visible region", style = MaterialTheme.typography.bodyLarge)
93+
Text(
94+
text = "Saves tiles for offline use",
95+
style = MaterialTheme.typography.bodySmall,
96+
color = MaterialTheme.colorScheme.onSurfaceVariant,
97+
)
98+
}
99+
}
100+
101+
// Existing packs
102+
if (offlineManager.packs.isNotEmpty()) {
103+
Text(
104+
text = "Downloaded Regions",
105+
style = MaterialTheme.typography.titleSmall,
106+
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
107+
)
108+
offlineManager.packs.toList().forEach { pack ->
109+
key(pack.hashCode()) {
110+
OfflinePackListItem(pack = pack, offlineManager = offlineManager) {
111+
Text(pack.metadata?.decodeToString().orEmpty().ifBlank { "Unnamed Region" })
112+
}
113+
}
114+
}
115+
}
116+
}
117+
},
118+
confirmButton = { TextButton(onClick = { showDialog = false }) { Text("Done") } },
119+
)
120+
}
121+
122+
// Expose the toggle via a side effect — the parent screen will call this
123+
// by rendering OfflineMapContent and using the showDialog state
124+
IconButton(onClick = { showDialog = true }) {
125+
Icon(imageVector = MeshtasticIcons.CloudDownload, contentDescription = "Offline Maps")
126+
}
127+
}

feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,14 @@ import androidx.compose.ui.Modifier
3232
import androidx.lifecycle.compose.collectAsStateWithLifecycle
3333
import kotlinx.coroutines.launch
3434
import org.jetbrains.compose.resources.stringResource
35+
import org.maplibre.compose.camera.CameraMoveReason
3536
import org.maplibre.compose.camera.CameraPosition
3637
import org.maplibre.compose.camera.rememberCameraState
38+
import org.maplibre.compose.location.BearingUpdate
3739
import org.maplibre.compose.location.LocationTrackingEffect
3840
import org.maplibre.compose.location.rememberNullLocationProvider
3941
import org.maplibre.compose.location.rememberUserLocationState
42+
import org.maplibre.compose.map.GestureOptions
4043
import org.meshtastic.core.common.util.nowSeconds
4144
import org.meshtastic.core.resources.Res
4245
import org.meshtastic.core.resources.map
@@ -46,6 +49,7 @@ import org.meshtastic.feature.map.component.MapControlsOverlay
4649
import org.meshtastic.feature.map.component.MapFilterDropdown
4750
import org.meshtastic.feature.map.component.MapStyleSelector
4851
import org.meshtastic.feature.map.component.MaplibreMapContent
52+
import org.meshtastic.feature.map.model.MapStyle
4953
import org.meshtastic.proto.Waypoint
5054
import org.maplibre.spatialk.geojson.Position as GeoPosition
5155

@@ -90,12 +94,27 @@ fun MapScreen(
9094

9195
val scope = rememberCoroutineScope()
9296

93-
// Location tracking state
97+
// Location tracking state: 3-mode cycling (Off → Track → TrackBearing → Off)
9498
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
99+
var bearingUpdate by remember { mutableStateOf(BearingUpdate.TRACK_LOCATION) }
95100
val locationProvider = rememberLocationProviderOrNull()
96101
val locationState = rememberUserLocationState(locationProvider ?: rememberNullLocationProvider())
97102
val locationAvailable = locationProvider != null
98103

104+
// Derive gesture options from location tracking state
105+
val gestureOptions =
106+
remember(isLocationTrackingEnabled, bearingUpdate) {
107+
if (isLocationTrackingEnabled) {
108+
when (bearingUpdate) {
109+
BearingUpdate.IGNORE -> GestureOptions.PositionLocked
110+
BearingUpdate.ALWAYS_NORTH -> GestureOptions.ZoomOnly
111+
BearingUpdate.TRACK_LOCATION -> GestureOptions.ZoomOnly
112+
}
113+
} else {
114+
GestureOptions.Standard
115+
}
116+
}
117+
99118
// Animate to waypoint when waypointId is provided (deep-link)
100119
val selectedWaypointId by viewModel.selectedWaypointId.collectAsStateWithLifecycle()
101120
LaunchedEffect(selectedWaypointId, waypoints) {
@@ -148,13 +167,15 @@ fun MapScreen(
148167
myNodeNum = viewModel.myNodeNum,
149168
showWaypoints = filterState.showWaypoints,
150169
showPrecisionCircle = filterState.showPrecisionCircle,
170+
showHillshade = selectedMapStyle == MapStyle.Terrain,
151171
onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) },
152172
onMapLongClick = { position ->
153173
longPressPosition = position
154174
editingWaypointId = null
155175
showWaypointDialog = true
156176
},
157177
modifier = Modifier.fillMaxSize(),
178+
gestureOptions = gestureOptions,
158179
onCameraMoved = { position -> viewModel.saveCameraPosition(position) },
159180
onWaypointClick = { wpId ->
160181
editingWaypointId = wpId
@@ -166,8 +187,19 @@ fun MapScreen(
166187

167188
// Auto-pan camera when location tracking is enabled
168189
if (locationAvailable) {
169-
LocationTrackingEffect(locationState = locationState, enabled = isLocationTrackingEnabled) {
170-
cameraState.updateFromLocation()
190+
LocationTrackingEffect(
191+
locationState = locationState,
192+
enabled = isLocationTrackingEnabled,
193+
trackBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION,
194+
) {
195+
cameraState.updateFromLocation(updateBearing = bearingUpdate)
196+
}
197+
198+
// Cancel tracking when user manually pans the map
199+
LaunchedEffect(cameraState.moveReason) {
200+
if (cameraState.moveReason == CameraMoveReason.GESTURE && isLocationTrackingEnabled) {
201+
isLocationTrackingEnabled = false
202+
}
171203
}
172204
}
173205

@@ -176,6 +208,7 @@ fun MapScreen(
176208
modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues),
177209
bearing = cameraState.position.bearing.toFloat(),
178210
onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } },
211+
followPhoneBearing = isLocationTrackingEnabled && bearingUpdate == BearingUpdate.TRACK_LOCATION,
179212
filterDropdownContent = {
180213
MapFilterDropdown(
181214
expanded = filterMenuExpanded,
@@ -190,8 +223,30 @@ fun MapScreen(
190223
mapTypeContent = {
191224
MapStyleSelector(selectedStyle = selectedMapStyle, onSelectStyle = viewModel::selectMapStyle)
192225
},
226+
layersContent = { OfflineMapContent(styleUri = selectedMapStyle.styleUri, cameraState = cameraState) },
193227
isLocationTrackingEnabled = isLocationTrackingEnabled,
194-
onToggleLocationTracking = { isLocationTrackingEnabled = !isLocationTrackingEnabled },
228+
isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION,
229+
onToggleLocationTracking = {
230+
if (!isLocationTrackingEnabled) {
231+
// Off → Track with bearing
232+
bearingUpdate = BearingUpdate.TRACK_LOCATION
233+
isLocationTrackingEnabled = true
234+
} else {
235+
when (bearingUpdate) {
236+
BearingUpdate.TRACK_LOCATION -> {
237+
// TrackBearing → TrackNorth
238+
bearingUpdate = BearingUpdate.ALWAYS_NORTH
239+
}
240+
BearingUpdate.ALWAYS_NORTH -> {
241+
// TrackNorth → Off
242+
isLocationTrackingEnabled = false
243+
}
244+
BearingUpdate.IGNORE -> {
245+
isLocationTrackingEnabled = false
246+
}
247+
}
248+
}
249+
},
195250
)
196251
}
197252
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.map
18+
19+
import androidx.compose.runtime.Composable
20+
21+
/**
22+
* Returns `true` if the platform supports offline map tile management.
23+
* - Android: `true` (backed by MapLibre Native).
24+
* - iOS: `true` (backed by MapLibre Native).
25+
* - Desktop/JS: `false` (no offline support).
26+
*/
27+
@Composable expect fun isOfflineManagerAvailable(): Boolean
28+
29+
/**
30+
* Renders platform-specific offline map management UI if the platform supports it. The composable receives the current
31+
* style URI and [cameraState] for downloading the visible region.
32+
*
33+
* On unsupported platforms, this is a no-op.
34+
*/
35+
@Composable expect fun OfflineMapContent(styleUri: String, cameraState: org.maplibre.compose.camera.CameraState)

feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import org.meshtastic.core.ui.icon.LocationDisabled
3838
import org.meshtastic.core.ui.icon.MapCompass
3939
import org.meshtastic.core.ui.icon.MeshtasticIcons
4040
import org.meshtastic.core.ui.icon.MyLocation
41+
import org.meshtastic.core.ui.icon.NearMe
4142
import org.meshtastic.core.ui.icon.Refresh
4243
import org.meshtastic.core.ui.icon.Tune
4344
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@@ -70,6 +71,7 @@ fun MapControlsOverlay(
7071
mapTypeContent: @Composable () -> Unit = {},
7172
layersContent: @Composable () -> Unit = {},
7273
isLocationTrackingEnabled: Boolean = false,
74+
isTrackingBearing: Boolean = false,
7375
onToggleLocationTracking: () -> Unit = {},
7476
showRefresh: Boolean = false,
7577
isRefreshing: Boolean = false,
@@ -114,10 +116,16 @@ fun MapControlsOverlay(
114116
}
115117
}
116118

117-
// Location tracking button
119+
// Location tracking button — 3 states: Off (MyLocation), Tracking (LocationDisabled), TrackingBearing (NearMe)
118120
MapButton(
119-
icon = if (isLocationTrackingEnabled) MeshtasticIcons.LocationDisabled else MeshtasticIcons.MyLocation,
121+
icon =
122+
when {
123+
!isLocationTrackingEnabled -> MeshtasticIcons.MyLocation
124+
isTrackingBearing -> MeshtasticIcons.NearMe
125+
else -> MeshtasticIcons.LocationDisabled
126+
},
120127
contentDescription = stringResource(Res.string.toggle_my_position),
128+
iconTint = if (isLocationTrackingEnabled) MaterialTheme.colorScheme.primary else null,
121129
onClick = onToggleLocationTracking,
122130
)
123131
}

0 commit comments

Comments
 (0)