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
new file mode 100644
index 000000000..d98dc681a
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.location.LocationProvider
+import org.maplibre.compose.location.rememberDefaultLocationProvider
+
+@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = rememberDefaultLocationProvider()
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
new file mode 100644
index 000000000..7bda5766d
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.location.LocationProvider
+
+/**
+ * Returns a platform-appropriate [LocationProvider], or `null` if the platform doesn't support location.
+ * - Android: uses the platform `LocationManager` via `rememberDefaultLocationProvider()`.
+ * - iOS: uses `CLLocationManager` via `rememberDefaultLocationProvider()`.
+ * - Desktop/JS: returns `null` (no location hardware).
+ */
+@Composable expect fun rememberLocationProviderOrNull(): LocationProvider?
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 92291d3f3..d610935e0 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
@@ -23,16 +23,35 @@ import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+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.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
+import org.maplibre.compose.location.LocationTrackingEffect
+import org.maplibre.compose.location.rememberNullLocationProvider
+import org.maplibre.compose.location.rememberUserLocationState
+import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.map.component.EditWaypointDialog
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.proto.Waypoint
+import org.maplibre.spatialk.geojson.Position as GeoPosition
+
+/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */
+private const val COORDINATE_SCALE = 1e-7
+private const val WAYPOINT_ZOOM = 15.0
/**
* 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
* This replaces the previous flavor-specific Google Maps and OSMDroid implementations with a single cross-platform
* composable.
*/
+@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun MapScreen(
onClickNodeChip: (Int) -> Unit,
@@ -55,11 +75,55 @@ fun MapScreen(
val waypoints by viewModel.waypoints.collectAsStateWithLifecycle()
val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle()
+ val selectedMapStyle by viewModel.selectedMapStyle.collectAsStateWithLifecycle()
LaunchedEffect(waypointId) { viewModel.setWaypointId(waypointId) }
val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition)
+ var filterMenuExpanded by remember { mutableStateOf(false) }
+
+ // Waypoint dialog state
+ var showWaypointDialog by remember { mutableStateOf(false) }
+ var longPressPosition by remember { mutableStateOf(null) }
+ var editingWaypointId by remember { mutableStateOf(null) }
+
+ val scope = rememberCoroutineScope()
+
+ // Location tracking state
+ var isLocationTrackingEnabled by remember { mutableStateOf(false) }
+ val locationProvider = rememberLocationProviderOrNull()
+ val locationState = rememberUserLocationState(locationProvider ?: rememberNullLocationProvider())
+ val locationAvailable = locationProvider != null
+
+ // Animate to waypoint when waypointId is provided (deep-link)
+ val selectedWaypointId by viewModel.selectedWaypointId.collectAsStateWithLifecycle()
+ LaunchedEffect(selectedWaypointId, waypoints) {
+ val wpId = selectedWaypointId ?: return@LaunchedEffect
+ val packet = waypoints[wpId] ?: return@LaunchedEffect
+ val wpt = packet.waypoint ?: return@LaunchedEffect
+ val lat = (wpt.latitude_i ?: 0) * COORDINATE_SCALE
+ val lng = (wpt.longitude_i ?: 0) * COORDINATE_SCALE
+ if (lat != 0.0 || lng != 0.0) {
+ cameraState.animateTo(
+ CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = WAYPOINT_ZOOM),
+ )
+ }
+ }
+
+ // Apply favorites and last-heard filters to the node list
+ val myNum = viewModel.myNodeNum
+ val filteredNodes =
+ remember(nodesWithPosition, filterState, myNum) {
+ nodesWithPosition
+ .filter { node -> !filterState.onlyFavorites || node.isFavorite || node.num == myNum }
+ .filter { node ->
+ filterState.lastHeardFilter.seconds == 0L ||
+ (nowSeconds - node.lastHeard) <= filterState.lastHeardFilter.seconds ||
+ node.num == myNum
+ }
+ }
+
@Suppress("ViewModelForwarding")
Scaffold(
modifier = modifier,
@@ -77,7 +141,7 @@ fun MapScreen(
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
MaplibreMapContent(
- nodes = nodesWithPosition,
+ nodes = filteredNodes,
waypoints = waypoints,
baseStyle = baseStyle,
cameraState = cameraState,
@@ -86,20 +150,95 @@ fun MapScreen(
showPrecisionCircle = filterState.showPrecisionCircle,
onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) },
onMapLongClick = { position ->
- // TODO: open waypoint creation dialog at position
+ longPressPosition = position
+ editingWaypointId = null
+ showWaypointDialog = true
},
modifier = Modifier.fillMaxSize(),
onCameraMoved = { position -> viewModel.saveCameraPosition(position) },
+ onWaypointClick = { wpId ->
+ editingWaypointId = wpId
+ longPressPosition = null
+ showWaypointDialog = true
+ },
+ locationState = if (isLocationTrackingEnabled && locationAvailable) locationState else null,
)
+ // Auto-pan camera when location tracking is enabled
+ if (locationAvailable) {
+ LocationTrackingEffect(locationState = locationState, enabled = isLocationTrackingEnabled) {
+ cameraState.updateFromLocation()
+ }
+ }
+
MapControlsOverlay(
- onToggleFilterMenu = {},
+ onToggleFilterMenu = { filterMenuExpanded = !filterMenuExpanded },
modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues),
bearing = cameraState.position.bearing.toFloat(),
- onCompassClick = {},
- isLocationTrackingEnabled = false,
- onToggleLocationTracking = {},
+ onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } },
+ filterDropdownContent = {
+ MapFilterDropdown(
+ expanded = filterMenuExpanded,
+ onDismissRequest = { filterMenuExpanded = false },
+ filterState = filterState,
+ onToggleFavorites = viewModel::toggleOnlyFavorites,
+ onToggleWaypoints = viewModel::toggleShowWaypointsOnMap,
+ onTogglePrecisionCircle = viewModel::toggleShowPrecisionCircleOnMap,
+ onSetLastHeardFilter = viewModel::setLastHeardFilter,
+ )
+ },
+ mapTypeContent = {
+ MapStyleSelector(selectedStyle = selectedMapStyle, onSelectStyle = viewModel::selectMapStyle)
+ },
+ isLocationTrackingEnabled = isLocationTrackingEnabled,
+ onToggleLocationTracking = { isLocationTrackingEnabled = !isLocationTrackingEnabled },
)
}
}
+
+ // Waypoint creation/edit dialog
+ if (showWaypointDialog) {
+ val editingPacket = editingWaypointId?.let { waypoints[it] }
+ val editingWaypoint = editingPacket?.waypoint
+
+ EditWaypointDialog(
+ onDismiss = {
+ showWaypointDialog = false
+ editingWaypointId = null
+ longPressPosition = null
+ },
+ onSend = { name, description, icon, locked, expire ->
+ val myNodeNum = viewModel.myNodeNum ?: 0
+ val wpt =
+ Waypoint(
+ id = editingWaypoint?.id ?: viewModel.generatePacketId(),
+ name = name,
+ description = description,
+ icon = icon,
+ locked_to = if (locked) myNodeNum else 0,
+ latitude_i =
+ if (editingWaypoint != null) {
+ editingWaypoint.latitude_i
+ } else {
+ longPressPosition?.let { (it.latitude / COORDINATE_SCALE).toInt() } ?: 0
+ },
+ longitude_i =
+ if (editingWaypoint != null) {
+ editingWaypoint.longitude_i
+ } else {
+ longPressPosition?.let { (it.longitude / COORDINATE_SCALE).toInt() } ?: 0
+ },
+ expire = expire,
+ )
+ viewModel.sendWaypoint(wpt)
+ },
+ onDelete = editingWaypoint?.let { wpt -> { viewModel.deleteWaypoint(wpt.id) } },
+ initialName = editingWaypoint?.name ?: "",
+ initialDescription = editingWaypoint?.description ?: "",
+ initialIcon = editingWaypoint?.icon ?: 0,
+ initialLocked = (editingWaypoint?.locked_to ?: 0) != 0,
+ isEditing = editingWaypoint != null,
+ position = longPressPosition,
+ )
+ }
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
new file mode 100644
index 000000000..1392ab1aa
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright (c) 2025-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.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.cancel
+import org.meshtastic.core.resources.delete
+import org.meshtastic.core.resources.description
+import org.meshtastic.core.resources.name
+import org.meshtastic.core.resources.send
+import org.meshtastic.feature.map.util.convertIntToEmoji
+import org.maplibre.spatialk.geojson.Position as GeoPosition
+
+private const val MAX_NAME_LENGTH = 29
+private const val MAX_DESCRIPTION_LENGTH = 99
+private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin
+
+/**
+ * Dialog for creating or editing a waypoint on the map.
+ *
+ * Replaces the old Android-specific `EditWaypointDialog` with a fully cross-platform Compose Multiplatform version.
+ * Date/time picker for expiry is deferred (requires platform-specific pickers or CMP M3 DatePicker availability).
+ */
+@Suppress("LongParameterList", "LongMethod")
+@Composable
+fun EditWaypointDialog(
+ onDismiss: () -> Unit,
+ onSend: (name: String, description: String, icon: Int, locked: Boolean, expire: Int) -> Unit,
+ onDelete: (() -> Unit)? = null,
+ initialName: String = "",
+ initialDescription: String = "",
+ initialIcon: Int = DEFAULT_EMOJI,
+ initialLocked: Boolean = false,
+ isEditing: Boolean = false,
+ position: GeoPosition? = null,
+) {
+ var name by remember { mutableStateOf(initialName) }
+ var description by remember { mutableStateOf(initialDescription) }
+ var emojiCodepoint by remember { mutableIntStateOf(if (initialIcon != 0) initialIcon else DEFAULT_EMOJI) }
+ var locked by remember { mutableStateOf(initialLocked) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(
+ text = if (isEditing) "Edit Waypoint" else "New Waypoint",
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ },
+ text = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ // Emoji + Name row
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = convertIntToEmoji(emojiCodepoint),
+ style = MaterialTheme.typography.headlineLarge,
+ modifier = Modifier.padding(end = 8.dp),
+ )
+ OutlinedTextField(
+ value = name,
+ onValueChange = { if (it.length <= MAX_NAME_LENGTH) name = it },
+ label = { Text(stringResource(Res.string.name)) },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Description
+ OutlinedTextField(
+ value = description,
+ onValueChange = { if (it.length <= MAX_DESCRIPTION_LENGTH) description = it },
+ label = { Text(stringResource(Res.string.description)) },
+ maxLines = 3,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Lock toggle
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Lock to my node", style = MaterialTheme.typography.bodyMedium)
+ Switch(checked = locked, onCheckedChange = { locked = it })
+ }
+
+ // Position info
+ if (position != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "${position.latitude}, ${position.longitude}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ dismissButton = {
+ Row {
+ if (onDelete != null) {
+ TextButton(
+ onClick = {
+ onDelete()
+ onDismiss()
+ },
+ ) {
+ Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onSend(name, description, emojiCodepoint, locked, 0)
+ onDismiss()
+ },
+ enabled = name.isNotBlank(),
+ ) {
+ Text(stringResource(Res.string.send))
+ }
+ },
+ )
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt
new file mode 100644
index 000000000..53f3d5dc2
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2025-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.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.last_heard_filter_label
+import org.meshtastic.core.resources.only_favorites
+import org.meshtastic.core.resources.show_precision_circle
+import org.meshtastic.core.resources.show_waypoints
+import org.meshtastic.core.ui.icon.Favorite
+import org.meshtastic.core.ui.icon.Lens
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.PinDrop
+import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
+import org.meshtastic.feature.map.LastHeardFilter
+import kotlin.math.roundToInt
+
+/**
+ * Dropdown menu for filtering map markers by favorites, waypoints, precision circles, and last-heard time.
+ *
+ * Mirrors the old Google/F-Droid `MapFilterDropdown` — checkboxes for boolean toggles and a slider for last-heard time
+ * filter.
+ */
+@Composable
+internal fun MapFilterDropdown(
+ expanded: Boolean,
+ onDismissRequest: () -> Unit,
+ filterState: MapFilterState,
+ onToggleFavorites: () -> Unit,
+ onToggleWaypoints: () -> Unit,
+ onTogglePrecisionCircle: () -> Unit,
+ onSetLastHeardFilter: (LastHeardFilter) -> Unit,
+) {
+ DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.only_favorites)) },
+ onClick = onToggleFavorites,
+ leadingIcon = {
+ Icon(
+ imageVector = MeshtasticIcons.Favorite,
+ contentDescription = stringResource(Res.string.only_favorites),
+ )
+ },
+ trailingIcon = { Checkbox(checked = filterState.onlyFavorites, onCheckedChange = { onToggleFavorites() }) },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.show_waypoints)) },
+ onClick = onToggleWaypoints,
+ leadingIcon = {
+ Icon(
+ imageVector = MeshtasticIcons.PinDrop,
+ contentDescription = stringResource(Res.string.show_waypoints),
+ )
+ },
+ trailingIcon = { Checkbox(checked = filterState.showWaypoints, onCheckedChange = { onToggleWaypoints() }) },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.show_precision_circle)) },
+ onClick = onTogglePrecisionCircle,
+ leadingIcon = {
+ Icon(
+ imageVector = MeshtasticIcons.Lens,
+ contentDescription = stringResource(Res.string.show_precision_circle),
+ )
+ },
+ trailingIcon = {
+ Checkbox(checked = filterState.showPrecisionCircle, onCheckedChange = { onTogglePrecisionCircle() })
+ },
+ )
+ HorizontalDivider()
+ LastHeardSlider(filterState.lastHeardFilter, onSetLastHeardFilter)
+ }
+}
+
+@Composable
+private fun LastHeardSlider(currentFilter: LastHeardFilter, onSetFilter: (LastHeardFilter) -> Unit) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ val filterOptions = LastHeardFilter.entries
+ val selectedIndex = filterOptions.indexOf(currentFilter)
+ var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
+
+ Text(
+ text = stringResource(Res.string.last_heard_filter_label, stringResource(currentFilter.label)),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ Slider(
+ value = sliderPosition,
+ onValueChange = { sliderPosition = it },
+ onValueChangeFinished = {
+ val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
+ onSetFilter(filterOptions[newIndex])
+ },
+ valueRange = 0f..(filterOptions.size - 1).toFloat(),
+ steps = filterOptions.size - 2,
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt
new file mode 100644
index 000000000..a50c9e2a7
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapStyleSelector.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2025-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.component
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.map_style_selection
+import org.meshtastic.core.resources.selected_map_type
+import org.meshtastic.core.ui.icon.Check
+import org.meshtastic.core.ui.icon.Layers
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.feature.map.model.MapStyle
+
+/**
+ * Map style selector button + dropdown menu. Shows predefined [MapStyle] entries with a checkmark next to the currently
+ * selected style.
+ */
+@Composable
+internal fun MapStyleSelector(selectedStyle: MapStyle, onSelectStyle: (MapStyle) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+
+ Box {
+ MapButton(
+ icon = MeshtasticIcons.Layers,
+ contentDescription = stringResource(Res.string.map_style_selection),
+ onClick = { expanded = true },
+ )
+
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ MapStyle.entries.forEach { style ->
+ DropdownMenuItem(
+ text = { Text(stringResource(style.label)) },
+ onClick = {
+ onSelectStyle(style)
+ expanded = false
+ },
+ trailingIcon =
+ if (selectedStyle == style) {
+ {
+ Icon(
+ MeshtasticIcons.Check,
+ contentDescription = stringResource(Res.string.selected_map_type),
+ )
+ }
+ } else {
+ null
+ },
+ )
+ }
+ }
+ }
+}
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 19e65518b..f749d3a89 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
@@ -19,25 +19,33 @@ package org.meshtastic.feature.map.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
+import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.expressions.dsl.asString
import org.maplibre.compose.expressions.dsl.const
+import org.maplibre.compose.expressions.dsl.convertToColor
+import org.maplibre.compose.expressions.dsl.convertToNumber
+import org.maplibre.compose.expressions.dsl.dp
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.SymbolLayer
+import org.maplibre.compose.location.LocationPuck
+import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.GeoJsonOptions
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.util.ClickResult
+import org.maplibre.spatialk.geojson.Point
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.feature.map.util.nodesToFeatureCollection
@@ -50,6 +58,8 @@ private const val CLUSTER_MIN_POINTS = 10
private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f
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
/**
* Main map content composable using MapLibre Compose Multiplatform.
@@ -70,6 +80,8 @@ fun MaplibreMapContent(
onMapLongClick: (GeoPosition) -> Unit,
modifier: Modifier = Modifier,
onCameraMoved: (CameraPosition) -> Unit = {},
+ onWaypointClick: (Int) -> Unit = {},
+ locationState: UserLocationState? = null,
) {
MaplibreMap(
modifier = modifier,
@@ -86,12 +98,18 @@ fun MaplibreMapContent(
nodes = nodes,
myNodeNum = myNodeNum,
showPrecisionCircle = showPrecisionCircle,
+ cameraState = cameraState,
onNodeClick = onNodeClick,
)
// --- Waypoint markers ---
if (showWaypoints) {
- WaypointMarkerLayers(waypoints = waypoints)
+ WaypointMarkerLayers(waypoints = waypoints, onWaypointClick = onWaypointClick)
+ }
+
+ // --- User location puck ---
+ if (locationState != null) {
+ LocationPuck(idPrefix = "user-location", locationState = locationState, cameraState = cameraState)
}
}
@@ -103,14 +121,17 @@ fun MaplibreMapContent(
}
}
-/** Node markers rendered as clustered circles and symbols using GeoJSON source. */
+/** Node markers rendered as clustered circles with per-node colors and short name labels. */
+@Suppress("LongMethod")
@Composable
private fun NodeMarkerLayers(
nodes: List,
myNodeNum: Int?,
showPrecisionCircle: Boolean,
+ cameraState: CameraState,
onNodeClick: (Int) -> Unit,
) {
+ val coroutineScope = rememberCoroutineScope()
val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) }
val nodesSource =
@@ -120,7 +141,7 @@ private fun NodeMarkerLayers(
GeoJsonOptions(cluster = true, clusterRadius = CLUSTER_RADIUS, clusterMinPoints = CLUSTER_MIN_POINTS),
)
- // Cluster circles
+ // Cluster circles — tap to zoom in toward expansion
CircleLayer(
id = "node-clusters",
source = nodesSource,
@@ -130,6 +151,19 @@ private fun NodeMarkerLayers(
opacity = const(CLUSTER_OPACITY),
strokeWidth = const(2.dp),
strokeColor = const(Color.White),
+ onClick = { features ->
+ val cluster = features.firstOrNull() ?: return@CircleLayer ClickResult.Pass
+ val target = (cluster.geometry as? Point)?.coordinates ?: return@CircleLayer ClickResult.Pass
+ coroutineScope.launch {
+ cameraState.animateTo(
+ cameraState.position.copy(
+ target = target,
+ zoom = cameraState.position.zoom + CLUSTER_ZOOM_INCREMENT,
+ ),
+ )
+ }
+ ClickResult.Consume
+ },
)
// Cluster count labels
@@ -142,13 +176,13 @@ private fun NodeMarkerLayers(
textSize = const(1.2f.em),
)
- // Individual node markers
+ // Individual node markers with per-node background color
CircleLayer(
id = "node-markers",
source = nodesSource,
filter = !feature.has("cluster"),
radius = const(8.dp),
- color = const(NodeMarkerColor),
+ color = feature["background_color"].convertToColor(const(NodeMarkerColor)),
strokeWidth = const(2.dp),
strokeColor = const(Color.White),
onClick = { features ->
@@ -162,23 +196,44 @@ private fun NodeMarkerLayers(
},
)
- // Precision circles
+ // Short name labels below node markers
+ SymbolLayer(
+ id = "node-labels",
+ source = nodesSource,
+ filter = !feature.has("cluster"),
+ textField = feature["short_name"].asString(),
+ textSize = const(0.9f.em),
+ textOffset = offset(0f.em, LABEL_OFFSET_EM.em),
+ textColor = const(Color.DarkGray),
+ textAllowOverlap = const(true),
+ iconAllowOverlap = const(true),
+ )
+
+ // Precision circles — sized by precision_meters property
if (showPrecisionCircle) {
CircleLayer(
id = "node-precision",
source = nodesSource,
filter = !feature.has("cluster"),
- radius = const(40.dp), // TODO: scale by precision_meters and zoom
- color = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)),
+ radius = feature["precision_meters"].convertToNumber(const(0f)).dp,
+ color =
+ feature["background_color"].convertToColor(
+ const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)),
+ ),
+ opacity = const(PRECISION_CIRCLE_FILL_ALPHA),
strokeWidth = const(1.dp),
- strokeColor = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)),
+ strokeColor =
+ feature["background_color"].convertToColor(
+ const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)),
+ ),
+ strokeOpacity = const(PRECISION_CIRCLE_STROKE_ALPHA),
)
}
}
-/** Waypoint markers rendered as symbol layer with emoji icons. */
+/** Waypoint markers rendered as symbol layer with emoji icons and click handling. */
@Composable
-private fun WaypointMarkerLayers(waypoints: Map) {
+private fun WaypointMarkerLayers(waypoints: Map, onWaypointClick: (Int) -> Unit) {
val featureCollection = remember(waypoints) { waypointsToFeatureCollection(waypoints) }
val waypointSource = rememberGeoJsonSource(data = GeoJsonData.Features(featureCollection))
@@ -191,6 +246,15 @@ private fun WaypointMarkerLayers(waypoints: Map) {
textSize = const(2f.em),
textAllowOverlap = const(true),
iconAllowOverlap = const(true),
+ onClick = { features ->
+ val waypointId = features.firstOrNull()?.properties?.get("waypoint_id")?.toString()?.toIntOrNull()
+ if (waypointId != null) {
+ onWaypointClick(waypointId)
+ ClickResult.Consume
+ } else {
+ ClickResult.Pass
+ }
+ },
)
// Waypoint name labels below emoji
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 9c7e1220d..043772898 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
@@ -16,18 +16,23 @@
*/
package org.meshtastic.feature.map.component
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
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.MaplibreMap
+import org.maplibre.spatialk.geojson.BoundingBox
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.proto.Position
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_TRACK_ZOOM = 13.0
private const val COORDINATE_SCALE = 1e-7
+private const val BOUNDS_PADDING_DP = 48
/**
* Embeddable position-track map showing a polyline with markers for the given positions.
@@ -44,15 +49,28 @@ fun NodeTrackMap(
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
- val center =
+ val geoPositions =
remember(positions) {
- positions.firstOrNull()?.let { pos ->
+ positions.mapNotNull { pos ->
val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE
if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null
}
}
+ val center = remember(geoPositions) { geoPositions.firstOrNull() }
+
+ val boundingBox =
+ remember(geoPositions) {
+ if (geoPositions.size < 2) return@remember null
+ val lats = geoPositions.map { it.latitude }
+ val lngs = geoPositions.map { it.longitude }
+ BoundingBox(
+ southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()),
+ northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()),
+ )
+ }
+
val cameraState =
rememberCameraState(
firstPosition =
@@ -62,6 +80,11 @@ fun NodeTrackMap(
),
)
+ // Fit camera to bounds when the track has multiple positions.
+ LaunchedEffect(boundingBox) {
+ boundingBox?.let { cameraState.animateTo(boundingBox = it, padding = PaddingValues(BOUNDS_PADDING_DP.dp)) }
+ }
+
MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) {
NodeTrackLayers(
positions = positions,
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 e198397c4..9cf0fe133 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
@@ -16,12 +16,16 @@
*/
package org.meshtastic.feature.map.component
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
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.MaplibreMap
+import org.maplibre.spatialk.geojson.BoundingBox
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.proto.Position
@@ -29,6 +33,7 @@ import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_TRACEROUTE_ZOOM = 10.0
private const val COORDINATE_SCALE = 1e-7
+private const val BOUNDS_PADDING_DP = 64
/**
* Embeddable traceroute map showing forward/return route polylines with hop markers.
@@ -45,16 +50,28 @@ fun TracerouteMap(
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
- // Center the camera on the first node with a known position.
- val center =
+ val geoPositions =
remember(tracerouteNodePositions) {
- tracerouteNodePositions.values.firstOrNull()?.let { pos ->
+ tracerouteNodePositions.values.mapNotNull { pos ->
val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE
if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null
}
}
+ val center = remember(geoPositions) { geoPositions.firstOrNull() }
+
+ val boundingBox =
+ remember(geoPositions) {
+ if (geoPositions.size < 2) return@remember null
+ val lats = geoPositions.map { it.latitude }
+ val lngs = geoPositions.map { it.longitude }
+ BoundingBox(
+ southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()),
+ northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()),
+ )
+ }
+
val cameraState =
rememberCameraState(
firstPosition =
@@ -64,6 +81,11 @@ fun TracerouteMap(
),
)
+ // Fit camera to bounds when the traceroute has multiple node positions.
+ LaunchedEffect(boundingBox) {
+ boundingBox?.let { cameraState.animateTo(boundingBox = it, padding = PaddingValues(BOUNDS_PADDING_DP.dp)) }
+ }
+
MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) {
TracerouteLayers(
overlay = tracerouteOverlay,
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
index a28c76627..622703f05 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
@@ -54,8 +54,8 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature
put("via_mqtt", node.viaMqtt)
put("snr", node.snr.toDouble())
put("rssi", node.rssi)
- put("foreground_color", colors.first)
- put("background_color", colors.second)
+ put("foreground_color", intToHexColor(colors.first))
+ put("background_color", intToHexColor(colors.second))
put("has_precision", (pos.precision_bits ?: 0) in MIN_PRECISION_BITS..MAX_PRECISION_BITS)
put("precision_meters", precisionBitsToMeters(pos.precision_bits ?: 0))
}
@@ -159,6 +159,14 @@ private const val HALF_SHIFT = 10
private const val HIGH_SURROGATE_BASE = 0xD800
private const val LOW_SURROGATE_BASE = 0xDC00
private const val SURROGATE_MASK = 0x3FF
+private const val HEX_COLOR_MASK = 0xFFFFFF
+
+/** Convert an ARGB color integer to a hex color string (e.g. "#FF6750A4") for MapLibre expressions. */
+@Suppress("MagicNumber")
+internal fun intToHexColor(argb: Int): String {
+ val rgb = argb and HEX_COLOR_MASK
+ return "#${rgb.toString(16).padStart(6, '0').uppercase()}"
+}
/** Convert a Unicode code point integer to its emoji string representation. */
internal fun convertIntToEmoji(codePoint: Int): String = try {
diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
new file mode 100644
index 000000000..d98dc681a
--- /dev/null
+++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.location.LocationProvider
+import org.maplibre.compose.location.rememberDefaultLocationProvider
+
+@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = rememberDefaultLocationProvider()
diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
new file mode 100644
index 000000000..717deb534
--- /dev/null
+++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/LocationProviderFactory.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.location.LocationProvider
+
+/** Desktop has no location provider — return null so the UI disables location tracking. */
+@Composable actual fun rememberLocationProviderOrNull(): LocationProvider? = null