feat: enhance map navigation and waypoint handling (#4814)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-16 08:48:00 -05:00
committed by GitHub
parent 802aa09aab
commit 5edb8abd05
15 changed files with 95 additions and 16 deletions

View File

@@ -17,6 +17,7 @@
package org.meshtastic.app.map
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
@@ -34,8 +35,10 @@ class FdroidMapViewProvider : MapViewProvider {
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
waypointId: Int?,
) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,

View File

@@ -47,6 +47,12 @@ class MapViewModel(
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
fun setWaypointId(id: Int?) {
if (id != null) {
_selectedWaypointId.value = id
}
}
var mapStyleId: Int
get() = mapPrefs.mapStyle.value
set(value) {

View File

@@ -17,6 +17,7 @@
package org.meshtastic.app.map
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
@@ -34,8 +35,10 @@ class GoogleMapViewProvider : MapViewProvider {
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
waypointId: Int?,
) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,

View File

@@ -91,6 +91,20 @@ class MapViewModel(
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
fun setWaypointId(id: Int?) {
if (id != null && _selectedWaypointId.value != id) {
_selectedWaypointId.value = id
viewModelScope.launch {
val wpMap = waypoints.first { it.containsKey(id) }
wpMap[id]?.let { packet ->
val waypoint = packet.waypoint!!
val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7)
cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f)
}
}
}
}
private val targetLatLng =
googleMapsPrefs.cameraTargetLat.value
.takeIf { it != 0.0 }

View File

@@ -123,14 +123,30 @@ class GoogleMapsPrefsImpl(
}
override val cameraTargetLat: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0 }.stateIn(scope, SharingStarted.Eagerly, 0.0)
dataStore.data
.map {
try {
it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0
} catch (_: ClassCastException) {
it[floatPreferencesKey(KEY_CAMERA_TARGET_LAT_PREF.name)]?.toDouble() ?: 0.0
}
}
.stateIn(scope, SharingStarted.Eagerly, 0.0)
override fun setCameraTargetLat(value: Double) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } }
}
override val cameraTargetLng: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0 }.stateIn(scope, SharingStarted.Eagerly, 0.0)
dataStore.data
.map {
try {
it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0
} catch (_: ClassCastException) {
it[floatPreferencesKey(KEY_CAMERA_TARGET_LNG_PREF.name)]?.toDouble() ?: 0.0
}
}
.stateIn(scope, SharingStarted.Eagerly, 0.0)
override fun setCameraTargetLng(value: Double) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } }

View File

@@ -88,6 +88,7 @@ private fun ContactsEntryContent(
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<ContactsViewModel>()
val messageViewModel = koinViewModel<MessageViewModel>()
initialContactKey?.let { messageViewModel.setContactKey(it) }
AdaptiveContactsScreen(
backStack = backStack,

View File

@@ -26,12 +26,13 @@ import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
fun EntryProviderScope<NavKey>.mapGraph(backStack: NavBackStack<NavKey>) {
entry<MapRoutes.Map> {
entry<MapRoutes.Map> { args ->
val viewModel = koinViewModel<SharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
waypointId = args.waypointId,
)
}
}

View File

@@ -111,6 +111,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
entry<NodeDetailRoutes.NodeMap> { args ->
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(args.destNum)
NodeMapScreen(vm, onNavigateUp = { backStack.removeLastOrNull() })
}

View File

@@ -299,24 +299,36 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.Nodes
if (!onNodesList) {
backStack.clear()
backStack.add(destination.route)
if (backStack.isNotEmpty()) {
backStack[0] = destination.route
while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
} else {
backStack.add(destination.route)
}
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
TopLevelDestination.Conversations -> {
val onConversationsList = currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
backStack.clear()
backStack.add(destination.route)
if (backStack.isNotEmpty()) {
backStack[0] = destination.route
while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
} else {
backStack.add(destination.route)
}
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
else -> Unit
}
} else {
backStack.clear()
backStack.add(destination.route)
if (backStack.isNotEmpty()) {
backStack[0] = destination.route
while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
} else {
backStack.add(destination.route)
}
}
},
)

View File

@@ -66,7 +66,8 @@ fun AdaptiveNodeListScreen(
val currentKey = backStack.lastOrNull()
val isNodesRoute = currentKey is NodesRoutes.Nodes || currentKey is NodesRoutes.NodesGraph
val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null
val isFromDifferentGraph = previousKey !is NodesRoutes.NodesGraph && previousKey !is NodesRoutes.Nodes
val isFromDifferentGraph =
previousKey != null && previousKey !is NodesRoutes.NodesGraph && previousKey !is NodesRoutes.Nodes
if (isFromDifferentGraph && !isNodesRoute) {
// Navigate back via NavController to return to the previous screen

View File

@@ -37,6 +37,7 @@ interface MapViewProvider {
tracerouteOverlay: Any? = null,
tracerouteNodePositions: Map<Int, Any> = emptyMap(),
onTracerouteMappableCountChanged: (Int, Int) -> Unit = { _, _ -> },
waypointId: Int? = null,
)
}

View File

@@ -35,6 +35,7 @@ fun MapScreen(
navigateToNodeDetails: (Int) -> Unit,
modifier: Modifier = Modifier,
viewModel: SharedMapViewModel,
waypointId: Int? = null,
) {
val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
@@ -58,6 +59,7 @@ fun MapScreen(
modifier = Modifier.fillMaxSize().padding(paddingValues),
viewModel = viewModel,
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)
}
}

View File

@@ -18,8 +18,10 @@ package org.meshtastic.feature.map.node
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@@ -44,11 +46,19 @@ class NodeMapViewModel(
buildConfigProvider: BuildConfigProvider,
private val mapPrefs: MapPrefs,
) : ViewModel() {
private val destNum = savedStateHandle.get<Int>("destNum") ?: 0
private val destNumFromRoute = savedStateHandle.get<Int>("destNum")
private val manualDestNum = MutableStateFlow<Int?>(null)
private val destNumFlow =
combine(MutableStateFlow(destNumFromRoute), manualDestNum) { route, manual -> manual ?: route ?: 0 }
fun setDestNum(num: Int) {
manualDestNum.value = num
}
val node =
nodeRepository.nodeDBbyNum
.mapLatest { it[destNum] }
destNumFlow
.flatMapLatest { destNum -> nodeRepository.nodeDBbyNum.mapLatest { it[destNum] } }
.distinctUntilChanged()
.stateInWhileSubscribed(initialValue = null)
@@ -57,8 +67,9 @@ class NodeMapViewModel(
private val ourNodeNumFlow = nodeRepository.myNodeInfo.map { it?.myNodeNum }.distinctUntilChanged()
val positionLogs: StateFlow<List<Position>> =
ourNodeNumFlow
.map { if (destNum == it) MeshLog.NODE_NUM_LOCAL else destNum }
combine(ourNodeNumFlow, destNumFlow) { ourNodeNum, destNum ->
if (destNum == ourNodeNum) MeshLog.NODE_NUM_LOCAL else destNum
}
.distinctUntilChanged()
.flatMapLatest { logId ->
meshLogRepository.getMeshPacketsFrom(logId, PortNum.POSITION_APP.value).map { packets ->

View File

@@ -78,7 +78,8 @@ fun AdaptiveContactsScreen(
// Check if we navigated here from another screen (e.g., from Nodes or Map)
val previousKey = if (backStack.size > 1) backStack[backStack.size - 2] else null
val isFromDifferentGraph =
previousKey !is ContactsRoutes.ContactsGraph &&
previousKey != null &&
previousKey !is ContactsRoutes.ContactsGraph &&
previousKey !is ContactsRoutes.Contacts &&
previousKey !is ContactsRoutes.Messages

View File

@@ -151,6 +151,12 @@ class MessageViewModel(
}
}
fun setContactKey(contactKey: String) {
if (contactKeyForPagedMessages.value != contactKey) {
contactKeyForPagedMessages.value = contactKey
}
}
fun setTitle(title: String) {
viewModelScope.launch { _title.value = title }
}