Decouple NodeMapScreen from MetricsViewModel (#3323)

Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
Phil Oliver
2025-10-04 09:17:00 -04:00
committed by GitHub
parent 8b4397a825
commit ff95bc5311
7 changed files with 87 additions and 47 deletions

View File

@@ -24,9 +24,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import org.meshtastic.feature.map.addCopyright
import org.meshtastic.feature.map.addPolyline
import org.meshtastic.feature.map.addPositionMarkers
@@ -39,20 +37,16 @@ import org.osmdroid.util.GeoPoint
private const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(
metricsViewModel: MetricsViewModel = hiltViewModel(),
nodeMapViewModel: NodeMapViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val density = LocalDensity.current
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val geoPoints = state.positionLogs.map { GeoPoint(it.latitudeI * DEG_D, it.longitudeI * DEG_D) }
val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
val geoPoints = positionLogs.map { GeoPoint(it.latitudeI * DEG_D, it.longitudeI * DEG_D) }
val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = nodeMapViewModel.applicationId,
box = cameraView,
tileSource = metricsViewModel.tileSource,
tileSource = nodeMapViewModel.tileSource,
)
AndroidView(
@@ -64,7 +58,7 @@ fun NodeMapScreen(
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(state.positionLogs) {}
map.addPositionMarkers(positionLogs) {}
},
)
}

View File

@@ -21,32 +21,24 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.MetricsViewModel
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.MapView
import org.meshtastic.feature.map.node.NodeMapViewModel
@Composable
fun NodeMapScreen(
metricsViewModel: MetricsViewModel = hiltViewModel(),
nodeMapViewModel: NodeMapViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
) {
val state by metricsViewModel.state.collectAsState()
val positions = state.positionLogs
val destNum = state.node?.num
val ourNodeInfo by nodeMapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
val destNum = node?.num
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
ourNode = ourNodeInfo,
title = node?.user?.longName ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,

View File

@@ -62,6 +62,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.proto.toPosition
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.R
@@ -190,12 +191,6 @@ enum class TimeFrame(val seconds: Long, @StringRes val strRes: Int) {
private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
private fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) {
runCatching { Position.parseFrom(decoded.payload) }.getOrNull()
} else {
null
}
@Suppress("LongParameterList")
@HiltViewModel
class MetricsViewModel

View File

@@ -57,6 +57,7 @@ import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.R
import org.meshtastic.feature.map.node.NodeMapViewModel
fun NavGraphBuilder.nodesGraph(navController: NavHostController) {
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
@@ -90,6 +91,21 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
)
}
composable<NodeDetailRoutes.NodeMap>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.NodeMap>(basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/node_map"),
navDeepLink<NodeDetailRoutes.NodeMap>(basePath = "$DEEP_LINK_BASE_URI/node/node_map"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
NodeMapScreen(
hiltViewModel<NodeMapViewModel>(parentGraphBackStackEntry),
onNavigateUp = navController::navigateUp,
)
}
NodeDetailRoute.entries.forEach { entry ->
when (entry.route) {
is NodeDetailRoutes.DeviceMetrics ->
@@ -98,12 +114,6 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController) {
entry,
entry.screenComposable,
)
is NodeDetailRoutes.NodeMap ->
addNodeDetailScreenComposable<NodeDetailRoutes.NodeMap>(
navController,
entry,
entry.screenComposable,
)
is NodeDetailRoutes.PositionLog ->
addNodeDetailScreenComposable<NodeDetailRoutes.PositionLog>(
navController,
@@ -199,12 +209,6 @@ enum class NodeDetailRoute(
Icons.Default.Router,
{ metricsVM, onNavigateUp -> DeviceMetricsScreen(metricsVM, onNavigateUp) },
),
NODE_MAP(
R.string.node_map,
NodeDetailRoutes.NodeMap,
Icons.Default.LocationOn,
{ metricsVM, onNavigateUp -> NodeMapScreen(metricsVM, onNavigateUp = onNavigateUp) },
),
POSITION_LOG(
R.string.position_log,
NodeDetailRoutes.PositionLog,

View File

@@ -20,6 +20,8 @@ package org.meshtastic.core.proto
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.MeshProtos.Position
import java.text.DateFormat
import kotlin.time.Duration.Companion.days
@@ -38,3 +40,9 @@ fun MeshProtos.Position.formatPositionTime(dateFormat: DateFormat): String {
}
return timeText
}
fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) {
runCatching { Position.parseFrom(decoded.payload) }.getOrNull()
} else {
null
}

View File

@@ -30,6 +30,7 @@ dependencies {
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)

View File

@@ -17,18 +17,64 @@
package org.meshtastic.feature.map.node
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.geeksville.mesh.MeshProtos.Position
import com.geeksville.mesh.Portnums.PortNum
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.toList
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.proto.toPosition
import org.meshtastic.feature.map.model.CustomTileSource
import javax.inject.Inject
@HiltViewModel
class NodeMapViewModel @Inject constructor(nodeRepository: NodeRepository, buildConfigProvider: BuildConfigProvider) :
ViewModel() {
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
class NodeMapViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
nodeRepository: NodeRepository,
meshLogRepository: MeshLogRepository,
buildConfigProvider: BuildConfigProvider,
private val mapPrefs: MapPrefs,
) : ViewModel() {
private val destNum = savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum
val node =
nodeRepository.nodeDBbyNum
.mapLatest { it[destNum] }
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), null)
val applicationId = buildConfigProvider.applicationId
val positionLogs: StateFlow<List<Position>> =
meshLogRepository
.getMeshPacketsFrom(destNum!!, PortNum.POSITION_APP_VALUE)
.map { packets ->
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time || (old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
}
.toList()
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptyList())
val tileSource
get() = CustomTileSource.getTileSource(mapPrefs.mapStyle)
}