@@ -23,16 +23,35 @@ import androidx.compose.material3.Scaffold
2323import androidx.compose.runtime.Composable
2424import androidx.compose.runtime.LaunchedEffect
2525import 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
2630import androidx.compose.ui.Alignment
2731import androidx.compose.ui.Modifier
2832import androidx.lifecycle.compose.collectAsStateWithLifecycle
33+ import kotlinx.coroutines.launch
2934import org.jetbrains.compose.resources.stringResource
35+ import org.maplibre.compose.camera.CameraPosition
3036import 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
3141import org.meshtastic.core.resources.Res
3242import org.meshtastic.core.resources.map
3343import org.meshtastic.core.ui.component.MainAppBar
44+ import org.meshtastic.feature.map.component.EditWaypointDialog
3445import org.meshtastic.feature.map.component.MapControlsOverlay
46+ import org.meshtastic.feature.map.component.MapFilterDropdown
47+ import org.meshtastic.feature.map.component.MapStyleSelector
3548import 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
4565fun 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