Skip to content

Commit bcbc7e2

Browse files
jamesarichCopilot
andcommitted
refactor(map): architecture, expression DSL, and filter UX
- Decompose composables: MapControlsOverlay, NodeMarkerLayers, etc. - Modern Kotlin idioms: when expressions, takeIf, zipWithNext - MapLibre expression DSL: coalesce(), step(), switch/condition - Replace compass with DisappearingCompassButton (material3) - Replace last-heard Slider with SingleChoiceSegmentedButtonRow - Add active filter count badge on filter button - Add node search/filter by name in filter dropdown - 109 tests total across JVM + Android host Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 93574f5 commit bcbc7e2

22 files changed

Lines changed: 787 additions & 608 deletions

core/resources/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,7 @@
832832
<string name="show_waypoints">Show Waypoints</string>
833833
<string name="show_precision_circle">Show Precision Circles</string>
834834
<string name="show_weather_radar">Weather Radar</string>
835+
<string name="search_nodes_hint">Search nodes…</string>
835836
<string name="owm_api_key_hint">OpenWeatherMap API Key (global)</string>
836837
<string name="owm_info_title">Global Weather Radar</string>
837838
<string name="owm_info_message">Without an API key, weather radar shows US-only NOAA NEXRAD data.\n\nFor global coverage, enter a free OpenWeatherMap API key. Sign up at openweathermap.org/api — the free tier includes 1,000 tile requests per day.</string>

feature/map/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ kotlin {
3131
commonMain.dependencies {
3232
implementation(libs.jetbrains.navigation3.ui)
3333
implementation(libs.kotlinx.collections.immutable)
34-
api(libs.maplibre.compose)
34+
implementation(libs.maplibre.compose)
3535
implementation(libs.maplibre.compose.material3)
3636
implementation(projects.core.data)
3737
implementation(projects.core.database)

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

Lines changed: 49 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package org.meshtastic.feature.map
1818

1919
import androidx.lifecycle.ViewModel
20-
import androidx.lifecycle.viewModelScope
2120
import kotlinx.coroutines.CoroutineDispatcher
2221
import kotlinx.coroutines.flow.MutableStateFlow
2322
import kotlinx.coroutines.flow.StateFlow
@@ -30,7 +29,6 @@ import org.meshtastic.core.common.util.nowSeconds
3029
import org.meshtastic.core.model.DataPacket
3130
import org.meshtastic.core.model.Node
3231
import org.meshtastic.core.model.RadioController
33-
import org.meshtastic.core.model.TracerouteOverlay
3432
import org.meshtastic.core.repository.MapPrefs
3533
import org.meshtastic.core.repository.NodeRepository
3634
import org.meshtastic.core.repository.PacketRepository
@@ -42,7 +40,6 @@ import org.meshtastic.core.resources.one_hour
4240
import org.meshtastic.core.resources.two_days
4341
import org.meshtastic.core.ui.viewmodel.safeLaunch
4442
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
45-
import org.meshtastic.proto.Position
4643
import org.meshtastic.proto.Waypoint
4744
import org.meshtastic.core.common.util.ioDispatcher as defaultIoDispatcher
4845

@@ -99,38 +96,22 @@ open class BaseMapViewModel(
9996
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value)
10097
val showOnlyFavoritesOnMap: StateFlow<Boolean> = showOnlyFavorites.asStateFlow()
10198

102-
fun toggleOnlyFavorites() {
103-
val newValue = !showOnlyFavorites.value
104-
showOnlyFavorites.value = newValue
105-
mapPrefs.setShowOnlyFavorites(newValue)
106-
}
99+
fun toggleOnlyFavorites() = togglePref(showOnlyFavorites, mapPrefs::setShowOnlyFavorites)
107100

108101
private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap.value)
109102
val showWaypointsOnMap: StateFlow<Boolean> = showWaypoints.asStateFlow()
110103

111-
fun toggleShowWaypointsOnMap() {
112-
val newValue = !showWaypoints.value
113-
showWaypoints.value = newValue
114-
mapPrefs.setShowWaypointsOnMap(newValue)
115-
}
104+
fun toggleShowWaypointsOnMap() = togglePref(showWaypoints, mapPrefs::setShowWaypointsOnMap)
116105

117106
private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap.value)
118107
val showPrecisionCircleOnMap: StateFlow<Boolean> = showPrecisionCircle.asStateFlow()
119108

120-
fun toggleShowPrecisionCircleOnMap() {
121-
val newValue = !showPrecisionCircle.value
122-
showPrecisionCircle.value = newValue
123-
mapPrefs.setShowPrecisionCircleOnMap(newValue)
124-
}
109+
fun toggleShowPrecisionCircleOnMap() = togglePref(showPrecisionCircle, mapPrefs::setShowPrecisionCircleOnMap)
125110

126111
private val showWeatherRadar = MutableStateFlow(mapPrefs.showWeatherRadarOnMap.value)
127112
val showWeatherRadarOnMap: StateFlow<Boolean> = showWeatherRadar.asStateFlow()
128113

129-
fun toggleShowWeatherRadarOnMap() {
130-
val newValue = !showWeatherRadar.value
131-
showWeatherRadar.value = newValue
132-
mapPrefs.setShowWeatherRadarOnMap(newValue)
133-
}
114+
fun toggleShowWeatherRadarOnMap() = togglePref(showWeatherRadar, mapPrefs::setShowWeatherRadarOnMap)
134115

135116
private val owmApiKeyBuffer = MutableStateFlow(mapPrefs.openWeatherMapApiKey.value)
136117
val owmApiKey: StateFlow<String> = owmApiKeyBuffer.asStateFlow()
@@ -160,6 +141,13 @@ open class BaseMapViewModel(
160141

161142
fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum)
162143

144+
/** Toggle a boolean pref flow and persist the new value. */
145+
private inline fun togglePref(flow: MutableStateFlow<Boolean>, crossinline persist: (Boolean) -> Unit) {
146+
val toggled = !flow.value
147+
flow.value = toggled
148+
persist(toggled)
149+
}
150+
163151
fun deleteWaypoint(id: Int) =
164152
safeLaunch(context = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) }
165153

@@ -176,6 +164,12 @@ open class BaseMapViewModel(
176164
safeLaunch(context = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) }
177165
}
178166

167+
private val nodeSearchQuery = MutableStateFlow("")
168+
169+
fun setNodeSearchQuery(query: String) {
170+
nodeSearchQuery.value = query
171+
}
172+
179173
fun generatePacketId(): Int = radioController.getPacketId()
180174

181175
data class MapFilterState(
@@ -186,7 +180,21 @@ open class BaseMapViewModel(
186180
val owmApiKey: String,
187181
val lastHeardFilter: LastHeardFilter,
188182
val lastHeardTrackFilter: LastHeardFilter,
189-
)
183+
val nodeSearchQuery: String = "",
184+
) {
185+
/** Number of active non-default filters, shown as a badge on the filter button. */
186+
val activeFilterCount: Int
187+
get() =
188+
listOf(
189+
onlyFavorites,
190+
!showWaypoints,
191+
!showPrecisionCircle,
192+
showWeatherRadar,
193+
lastHeardFilter != LastHeardFilter.Any,
194+
nodeSearchQuery.isNotBlank(),
195+
)
196+
.count { it }
197+
}
190198

191199
val mapFilterStateFlow: StateFlow<MapFilterState> =
192200
combine(
@@ -209,8 +217,9 @@ open class BaseMapViewModel(
209217
},
210218
showWeatherRadarOnMap,
211219
owmApiKey,
212-
) { state, weatherRadar, apiKey ->
213-
state.copy(showWeatherRadar = weatherRadar, owmApiKey = apiKey)
220+
nodeSearchQuery,
221+
) { state, weatherRadar, apiKey, searchQuery ->
222+
state.copy(showWeatherRadar = weatherRadar, owmApiKey = apiKey, nodeSearchQuery = searchQuery)
214223
}
215224
.stateInWhileSubscribed(
216225
initialValue =
@@ -225,89 +234,27 @@ open class BaseMapViewModel(
225234
),
226235
)
227236

228-
/** Nodes with position, filtered by favorites and last-heard preferences. */
237+
/** Nodes with position, filtered by favorites, last-heard, and search query. */
229238
val filteredNodes: StateFlow<List<Node>> =
230239
combine(nodesWithPosition, mapFilterStateFlow) { nodes, filter ->
231240
val myNum = myNodeNum
232-
nodes
233-
.filter { node -> !filter.onlyFavorites || node.isFavorite || node.num == myNum }
234-
.filter { node ->
241+
val query = filter.nodeSearchQuery.trim().lowercase()
242+
nodes.filter { node ->
243+
val isMyNode = node.num == myNum
244+
val matchesFavorite = !filter.onlyFavorites || node.isFavorite || isMyNode
245+
val matchesLastHeard =
235246
filter.lastHeardFilter.seconds == 0L ||
236247
(nowSeconds - node.lastHeard) <= filter.lastHeardFilter.seconds ||
237-
node.num == myNum
238-
}
239-
}
240-
.stateInWhileSubscribed(initialValue = emptyList())
241-
}
242-
243-
/**
244-
* Result of resolving a [TracerouteOverlay]'s node nums into displayable [Node] instances.
245-
*
246-
* @property overlayNodeNums All unique node nums referenced by the traceroute.
247-
* @property nodesForMarkers Nodes to render as map markers (with snapshot positions when available).
248-
* @property nodeLookup Node-num-keyed map for polyline coordinate resolution.
249-
*/
250-
internal data class TracerouteNodeSelection(
251-
val overlayNodeNums: Set<Int>,
252-
val nodesForMarkers: List<Node>,
253-
val nodeLookup: Map<Int, Node>,
254-
)
255-
256-
/** Convenience extension that delegates to [tracerouteNodeSelection] using the VM's [getNodeOrFallback]. */
257-
internal fun BaseMapViewModel.tracerouteNodeSelection(
258-
tracerouteOverlay: TracerouteOverlay?,
259-
tracerouteNodePositions: Map<Int, Position>,
260-
nodes: List<Node>,
261-
): TracerouteNodeSelection = tracerouteNodeSelection(
262-
tracerouteOverlay = tracerouteOverlay,
263-
tracerouteNodePositions = tracerouteNodePositions,
264-
nodes = nodes,
265-
getNodeOrFallback = ::getNodeOrFallback,
266-
)
267-
268-
/**
269-
* Resolves traceroute overlay node nums into displayable [Node] instances. Snapshot positions (recorded at traceroute
270-
* time) take priority over live positions from the node database.
271-
*
272-
* @param getNodeOrFallback Provides a [Node] for a given num, falling back to a stub if not in the DB.
273-
*/
274-
internal fun tracerouteNodeSelection(
275-
tracerouteOverlay: TracerouteOverlay?,
276-
tracerouteNodePositions: Map<Int, Position>,
277-
nodes: List<Node>,
278-
getNodeOrFallback: (Int) -> Node,
279-
): TracerouteNodeSelection {
280-
val overlayNodeNums = tracerouteOverlay?.relatedNodeNums ?: emptySet()
281-
val tracerouteSnapshotNodes =
282-
if (tracerouteOverlay == null || tracerouteNodePositions.isEmpty()) {
283-
emptyList()
284-
} else {
285-
tracerouteNodePositions.map { (nodeNum, position) -> getNodeOrFallback(nodeNum).copy(position = position) }
286-
}
287-
288-
val nodesForMarkers =
289-
if (tracerouteOverlay != null) {
290-
if (tracerouteSnapshotNodes.isNotEmpty()) {
291-
tracerouteSnapshotNodes.filter { overlayNodeNums.contains(it.num) }
292-
} else {
293-
nodes.filter { overlayNodeNums.contains(it.num) }
248+
isMyNode
249+
val matchesSearch =
250+
query.isEmpty() ||
251+
node.user.short_name.lowercase().contains(query) ||
252+
node.user.long_name.lowercase().contains(query) ||
253+
isMyNode
254+
matchesFavorite && matchesLastHeard && matchesSearch
294255
}
295-
} else {
296-
nodes
297256
}
298-
299-
val nodesForLookup =
300-
if (tracerouteSnapshotNodes.isNotEmpty()) {
301-
tracerouteSnapshotNodes
302-
} else {
303-
nodes.filter { it.validPosition != null }
304-
}
305-
306-
return TracerouteNodeSelection(
307-
overlayNodeNums = overlayNodeNums,
308-
nodesForMarkers = nodesForMarkers,
309-
nodeLookup = nodesForLookup.associateBy { it.num },
310-
)
257+
.stateInWhileSubscribed(initialValue = emptyList())
311258
}
312259

313260
@Suppress("MagicNumber")

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

Lines changed: 37 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import org.maplibre.compose.location.LocationTrackingEffect
4141
import org.maplibre.compose.location.rememberNullLocationProvider
4242
import org.maplibre.compose.location.rememberUserLocationState
4343
import org.maplibre.compose.map.GestureOptions
44+
import org.maplibre.compose.material3.DisappearingCompassButton
4445
import org.maplibre.compose.material3.DisappearingScaleBar
4546
import org.maplibre.compose.material3.ExpandingAttributionButton
4647
import org.maplibre.compose.style.rememberStyleState
@@ -116,24 +117,22 @@ fun MapScreen(
116117
}
117118
}
118119

119-
// Location tracking state: 3-mode cycling (Off → Track → TrackBearing → Off)
120-
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
121-
var bearingUpdate by remember { mutableStateOf(BearingUpdate.TRACK_LOCATION) }
120+
// Location tracking state: 3-mode cycling (Off → TrackBearing → TrackNorth → Off)
121+
var locationTrackingMode by remember { mutableStateOf(LocationTrackingMode.OFF) }
122122
val locationProvider = rememberLocationProviderOrNull()
123123
val locationState = rememberUserLocationState(locationProvider ?: rememberNullLocationProvider())
124124
val locationAvailable = locationProvider != null
125125

126+
val isLocationTrackingEnabled = locationTrackingMode != LocationTrackingMode.OFF
127+
val bearingUpdate = locationTrackingMode.bearingUpdate
128+
126129
// Derive gesture options from location tracking state
127130
val gestureOptions =
128-
remember(isLocationTrackingEnabled, bearingUpdate) {
129-
if (isLocationTrackingEnabled) {
130-
when (bearingUpdate) {
131-
BearingUpdate.IGNORE -> GestureOptions.PositionLocked
132-
BearingUpdate.ALWAYS_NORTH -> GestureOptions.ZoomOnly
133-
BearingUpdate.TRACK_LOCATION -> GestureOptions.ZoomOnly
134-
}
135-
} else {
136-
GestureOptions.Standard
131+
remember(locationTrackingMode) {
132+
when (locationTrackingMode) {
133+
LocationTrackingMode.OFF -> GestureOptions.Standard
134+
LocationTrackingMode.TRACK_BEARING -> GestureOptions.ZoomOnly
135+
LocationTrackingMode.TRACK_NORTH -> GestureOptions.ZoomOnly
137136
}
138137
}
139138

@@ -198,26 +197,23 @@ fun MapScreen(
198197
LocationTrackingEffect(
199198
locationState = locationState,
200199
enabled = isLocationTrackingEnabled,
201-
trackBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION,
200+
trackBearing = locationTrackingMode == LocationTrackingMode.TRACK_BEARING,
202201
) {
203202
cameraState.updateFromLocation(updateBearing = bearingUpdate)
204203
}
205204

206205
// Cancel tracking when user manually pans the map
207206
LaunchedEffect(cameraState.moveReason) {
208207
if (cameraState.moveReason == CameraMoveReason.GESTURE && isLocationTrackingEnabled) {
209-
isLocationTrackingEnabled = false
210-
bearingUpdate = BearingUpdate.IGNORE
208+
locationTrackingMode = LocationTrackingMode.OFF
211209
}
212210
}
213211
}
214212

215213
MapControlsOverlay(
216214
onToggleFilterMenu = { filterMenuExpanded = !filterMenuExpanded },
217215
modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues),
218-
bearing = cameraState.position.bearing.toFloat(),
219-
onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } },
220-
followPhoneBearing = isLocationTrackingEnabled && bearingUpdate == BearingUpdate.TRACK_LOCATION,
216+
activeFilterCount = filterState.activeFilterCount,
221217
filterDropdownContent = {
222218
MapFilterDropdown(
223219
expanded = filterMenuExpanded,
@@ -229,6 +225,7 @@ fun MapScreen(
229225
onToggleWeatherRadar = viewModel::toggleShowWeatherRadarOnMap,
230226
onSetOwmApiKey = viewModel::setOwmApiKey,
231227
onSetLastHeardFilter = viewModel::setLastHeardFilter,
228+
onSetNodeSearchQuery = viewModel::setNodeSearchQuery,
232229
)
233230
},
234231
mapTypeContent = {
@@ -244,28 +241,14 @@ fun MapScreen(
244241
)
245242
},
246243
isLocationTrackingEnabled = isLocationTrackingEnabled,
247-
isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION,
248-
onToggleLocationTracking = {
249-
if (!isLocationTrackingEnabled) {
250-
// Off → Track with bearing
251-
bearingUpdate = BearingUpdate.TRACK_LOCATION
252-
isLocationTrackingEnabled = true
253-
} else {
254-
when (bearingUpdate) {
255-
BearingUpdate.TRACK_LOCATION -> {
256-
// TrackBearing → TrackNorth
257-
bearingUpdate = BearingUpdate.ALWAYS_NORTH
258-
}
259-
BearingUpdate.ALWAYS_NORTH -> {
260-
// TrackNorth → Off
261-
isLocationTrackingEnabled = false
262-
}
263-
BearingUpdate.IGNORE -> {
264-
isLocationTrackingEnabled = false
265-
}
266-
}
267-
}
268-
},
244+
isTrackingBearing = locationTrackingMode == LocationTrackingMode.TRACK_BEARING,
245+
onToggleLocationTracking = { locationTrackingMode = locationTrackingMode.next() },
246+
)
247+
248+
// Compass — auto-hides when north-up, resets bearing/tilt on tap
249+
DisappearingCompassButton(
250+
cameraState = cameraState,
251+
modifier = Modifier.align(Alignment.TopStart).padding(paddingValues).padding(MAP_OVERLAY_PADDING),
269252
)
270253

271254
// Zoom controls
@@ -341,3 +324,17 @@ fun MapScreen(
341324
)
342325
}
343326
}
327+
328+
/** Location tracking state machine: Off → TrackBearing → TrackNorth → Off. */
329+
private enum class LocationTrackingMode(val bearingUpdate: BearingUpdate) {
330+
OFF(BearingUpdate.IGNORE),
331+
TRACK_BEARING(BearingUpdate.TRACK_LOCATION),
332+
TRACK_NORTH(BearingUpdate.ALWAYS_NORTH),
333+
;
334+
335+
fun next(): LocationTrackingMode = when (this) {
336+
OFF -> TRACK_BEARING
337+
TRACK_BEARING -> TRACK_NORTH
338+
TRACK_NORTH -> OFF
339+
}
340+
}

0 commit comments

Comments
 (0)