Skip to content

Commit e9d6e2b

Browse files
committed
feat(map): add feature parity — filters, style selector, waypoint dialog, cluster zoom, bounds fitting, location tracking
Wire remaining map feature gaps identified in the parity audit: - MapFilterDropdown: favorites, waypoints, precision circle toggles and last-heard slider matching the old Google/OSMDroid filter UIs - MapStyleSelector: dropdown with 5 predefined MapStyle entries - EditWaypointDialog: create, edit, delete waypoints via long-press or marker tap, with icon picker and lock toggle - Cluster zoom-to-expand: tap a cluster circle to zoom +2 levels centered on the cluster position - Bounds fitting: NodeTrackMap and TracerouteMap compute a BoundingBox from all positions and animate the camera to fit on first load - Location tracking: expect/actual rememberLocationProviderOrNull() bridges platform GPS into maplibre-compose LocationPuck with LocationTrackingEffect for auto-pan and bearing follow - Per-node marker colors via data-driven convertToColor() expressions - Waypoint camera animation on deep-link selection - Compass click resets bearing to north
1 parent 2cf4e8f commit e9d6e2b

12 files changed

Lines changed: 750 additions & 24 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
import org.maplibre.compose.location.LocationProvider
21+
import org.maplibre.compose.location.rememberDefaultLocationProvider
22+
23+
@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = rememberDefaultLocationProvider()
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
import org.maplibre.compose.location.LocationProvider
21+
22+
/**
23+
* Returns a platform-appropriate [LocationProvider], or `null` if the platform doesn't support location.
24+
* - Android: uses the platform `LocationManager` via `rememberDefaultLocationProvider()`.
25+
* - iOS: uses `CLLocationManager` via `rememberDefaultLocationProvider()`.
26+
* - Desktop/JS: returns `null` (no location hardware).
27+
*/
28+
@Composable expect fun rememberLocationProviderOrNull(): LocationProvider?

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

Lines changed: 145 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,35 @@ import androidx.compose.material3.Scaffold
2323
import androidx.compose.runtime.Composable
2424
import androidx.compose.runtime.LaunchedEffect
2525
import androidx.compose.runtime.getValue
26+
import androidx.compose.runtime.mutableStateOf
27+
import androidx.compose.runtime.remember
28+
import androidx.compose.runtime.rememberCoroutineScope
29+
import androidx.compose.runtime.setValue
2630
import androidx.compose.ui.Alignment
2731
import androidx.compose.ui.Modifier
2832
import androidx.lifecycle.compose.collectAsStateWithLifecycle
33+
import kotlinx.coroutines.launch
2934
import org.jetbrains.compose.resources.stringResource
35+
import org.maplibre.compose.camera.CameraPosition
3036
import org.maplibre.compose.camera.rememberCameraState
37+
import org.maplibre.compose.location.LocationTrackingEffect
38+
import org.maplibre.compose.location.rememberNullLocationProvider
39+
import org.maplibre.compose.location.rememberUserLocationState
40+
import org.meshtastic.core.common.util.nowSeconds
3141
import org.meshtastic.core.resources.Res
3242
import org.meshtastic.core.resources.map
3343
import org.meshtastic.core.ui.component.MainAppBar
44+
import org.meshtastic.feature.map.component.EditWaypointDialog
3445
import org.meshtastic.feature.map.component.MapControlsOverlay
46+
import org.meshtastic.feature.map.component.MapFilterDropdown
47+
import org.meshtastic.feature.map.component.MapStyleSelector
3548
import org.meshtastic.feature.map.component.MaplibreMapContent
49+
import org.meshtastic.proto.Waypoint
50+
import org.maplibre.spatialk.geojson.Position as GeoPosition
51+
52+
/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */
53+
private const val COORDINATE_SCALE = 1e-7
54+
private const val WAYPOINT_ZOOM = 15.0
3655

3756
/**
3857
* Main map screen composable. Uses MapLibre Compose Multiplatform to render an interactive map with mesh node markers,
@@ -41,6 +60,7 @@ import org.meshtastic.feature.map.component.MaplibreMapContent
4160
* This replaces the previous flavor-specific Google Maps and OSMDroid implementations with a single cross-platform
4261
* composable.
4362
*/
63+
@Suppress("LongMethod", "CyclomaticComplexMethod")
4464
@Composable
4565
fun MapScreen(
4666
onClickNodeChip: (Int) -> Unit,
@@ -55,11 +75,55 @@ fun MapScreen(
5575
val waypoints by viewModel.waypoints.collectAsStateWithLifecycle()
5676
val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
5777
val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle()
78+
val selectedMapStyle by viewModel.selectedMapStyle.collectAsStateWithLifecycle()
5879

5980
LaunchedEffect(waypointId) { viewModel.setWaypointId(waypointId) }
6081

6182
val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition)
6283

84+
var filterMenuExpanded by remember { mutableStateOf(false) }
85+
86+
// Waypoint dialog state
87+
var showWaypointDialog by remember { mutableStateOf(false) }
88+
var longPressPosition by remember { mutableStateOf<GeoPosition?>(null) }
89+
var editingWaypointId by remember { mutableStateOf<Int?>(null) }
90+
91+
val scope = rememberCoroutineScope()
92+
93+
// Location tracking state
94+
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
95+
val locationProvider = rememberLocationProviderOrNull()
96+
val locationState = rememberUserLocationState(locationProvider ?: rememberNullLocationProvider())
97+
val locationAvailable = locationProvider != null
98+
99+
// Animate to waypoint when waypointId is provided (deep-link)
100+
val selectedWaypointId by viewModel.selectedWaypointId.collectAsStateWithLifecycle()
101+
LaunchedEffect(selectedWaypointId, waypoints) {
102+
val wpId = selectedWaypointId ?: return@LaunchedEffect
103+
val packet = waypoints[wpId] ?: return@LaunchedEffect
104+
val wpt = packet.waypoint ?: return@LaunchedEffect
105+
val lat = (wpt.latitude_i ?: 0) * COORDINATE_SCALE
106+
val lng = (wpt.longitude_i ?: 0) * COORDINATE_SCALE
107+
if (lat != 0.0 || lng != 0.0) {
108+
cameraState.animateTo(
109+
CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = WAYPOINT_ZOOM),
110+
)
111+
}
112+
}
113+
114+
// Apply favorites and last-heard filters to the node list
115+
val myNum = viewModel.myNodeNum
116+
val filteredNodes =
117+
remember(nodesWithPosition, filterState, myNum) {
118+
nodesWithPosition
119+
.filter { node -> !filterState.onlyFavorites || node.isFavorite || node.num == myNum }
120+
.filter { node ->
121+
filterState.lastHeardFilter.seconds == 0L ||
122+
(nowSeconds - node.lastHeard) <= filterState.lastHeardFilter.seconds ||
123+
node.num == myNum
124+
}
125+
}
126+
63127
@Suppress("ViewModelForwarding")
64128
Scaffold(
65129
modifier = modifier,
@@ -77,7 +141,7 @@ fun MapScreen(
77141
) { paddingValues ->
78142
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
79143
MaplibreMapContent(
80-
nodes = nodesWithPosition,
144+
nodes = filteredNodes,
81145
waypoints = waypoints,
82146
baseStyle = baseStyle,
83147
cameraState = cameraState,
@@ -86,20 +150,95 @@ fun MapScreen(
86150
showPrecisionCircle = filterState.showPrecisionCircle,
87151
onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) },
88152
onMapLongClick = { position ->
89-
// TODO: open waypoint creation dialog at position
153+
longPressPosition = position
154+
editingWaypointId = null
155+
showWaypointDialog = true
90156
},
91157
modifier = Modifier.fillMaxSize(),
92158
onCameraMoved = { position -> viewModel.saveCameraPosition(position) },
159+
onWaypointClick = { wpId ->
160+
editingWaypointId = wpId
161+
longPressPosition = null
162+
showWaypointDialog = true
163+
},
164+
locationState = if (isLocationTrackingEnabled && locationAvailable) locationState else null,
93165
)
94166

167+
// Auto-pan camera when location tracking is enabled
168+
if (locationAvailable) {
169+
LocationTrackingEffect(locationState = locationState, enabled = isLocationTrackingEnabled) {
170+
cameraState.updateFromLocation()
171+
}
172+
}
173+
95174
MapControlsOverlay(
96-
onToggleFilterMenu = {},
175+
onToggleFilterMenu = { filterMenuExpanded = !filterMenuExpanded },
97176
modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues),
98177
bearing = cameraState.position.bearing.toFloat(),
99-
onCompassClick = {},
100-
isLocationTrackingEnabled = false,
101-
onToggleLocationTracking = {},
178+
onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } },
179+
filterDropdownContent = {
180+
MapFilterDropdown(
181+
expanded = filterMenuExpanded,
182+
onDismissRequest = { filterMenuExpanded = false },
183+
filterState = filterState,
184+
onToggleFavorites = viewModel::toggleOnlyFavorites,
185+
onToggleWaypoints = viewModel::toggleShowWaypointsOnMap,
186+
onTogglePrecisionCircle = viewModel::toggleShowPrecisionCircleOnMap,
187+
onSetLastHeardFilter = viewModel::setLastHeardFilter,
188+
)
189+
},
190+
mapTypeContent = {
191+
MapStyleSelector(selectedStyle = selectedMapStyle, onSelectStyle = viewModel::selectMapStyle)
192+
},
193+
isLocationTrackingEnabled = isLocationTrackingEnabled,
194+
onToggleLocationTracking = { isLocationTrackingEnabled = !isLocationTrackingEnabled },
102195
)
103196
}
104197
}
198+
199+
// Waypoint creation/edit dialog
200+
if (showWaypointDialog) {
201+
val editingPacket = editingWaypointId?.let { waypoints[it] }
202+
val editingWaypoint = editingPacket?.waypoint
203+
204+
EditWaypointDialog(
205+
onDismiss = {
206+
showWaypointDialog = false
207+
editingWaypointId = null
208+
longPressPosition = null
209+
},
210+
onSend = { name, description, icon, locked, expire ->
211+
val myNodeNum = viewModel.myNodeNum ?: 0
212+
val wpt =
213+
Waypoint(
214+
id = editingWaypoint?.id ?: viewModel.generatePacketId(),
215+
name = name,
216+
description = description,
217+
icon = icon,
218+
locked_to = if (locked) myNodeNum else 0,
219+
latitude_i =
220+
if (editingWaypoint != null) {
221+
editingWaypoint.latitude_i
222+
} else {
223+
longPressPosition?.let { (it.latitude / COORDINATE_SCALE).toInt() } ?: 0
224+
},
225+
longitude_i =
226+
if (editingWaypoint != null) {
227+
editingWaypoint.longitude_i
228+
} else {
229+
longPressPosition?.let { (it.longitude / COORDINATE_SCALE).toInt() } ?: 0
230+
},
231+
expire = expire,
232+
)
233+
viewModel.sendWaypoint(wpt)
234+
},
235+
onDelete = editingWaypoint?.let { wpt -> { viewModel.deleteWaypoint(wpt.id) } },
236+
initialName = editingWaypoint?.name ?: "",
237+
initialDescription = editingWaypoint?.description ?: "",
238+
initialIcon = editingWaypoint?.icon ?: 0,
239+
initialLocked = (editingWaypoint?.locked_to ?: 0) != 0,
240+
isEditing = editingWaypoint != null,
241+
position = longPressPosition,
242+
)
243+
}
105244
}

0 commit comments

Comments
 (0)