Refactor map layer management and navigation infrastructure (#4921)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-25 19:29:24 -05:00 committed by GitHub
parent b608a04ca4
commit a005231d94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 5408 additions and 3090 deletions

View file

@ -712,6 +712,10 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
var currentLayer by remember { mutableStateOf<com.google.maps.android.data.Layer?>(null) }
MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
// Cleanup old layer if we're reloading
currentLayer?.safeRemoveLayerFromMap()
currentLayer = null
val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
val layer =
try {
@ -727,7 +731,7 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
layer?.let {
if (layerItem.isVisible) {
it.addLayerToMap()
it.safeAddLayerToMap()
}
currentLayer = it
}
@ -735,7 +739,7 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
DisposableEffect(layerItem.id) {
onDispose {
currentLayer?.removeLayerFromMap()
currentLayer?.safeRemoveLayerFromMap()
currentLayer = null
}
}
@ -745,13 +749,33 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
LaunchedEffect(layerItem.isVisible) {
val layer = currentLayer ?: return@LaunchedEffect
if (layerItem.isVisible) {
if (!layer.isLayerOnMap) layer.addLayerToMap()
layer.safeAddLayerToMap()
} else {
if (layer.isLayerOnMap) layer.removeLayerFromMap()
layer.safeRemoveLayerFromMap()
}
}
}
private fun com.google.maps.android.data.Layer.safeRemoveLayerFromMap() {
try {
removeLayerFromMap()
} catch (e: Exception) {
// Log it and ignore. This specifically handles a NullPointerException in
// KmlRenderer.hasNestedContainers which can occur when disposing layers.
Logger.withTag("MapView").e(e) { "Error removing map layer" }
}
}
private fun com.google.maps.android.data.Layer.safeAddLayerToMap() {
try {
if (!isLayerOnMap) {
addLayerToMap()
}
} catch (e: Exception) {
Logger.withTag("MapView").e(e) { "Error adding map layer" }
}
}
internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
String(Character.toChars(unicodeCodePoint))
} catch (e: IllegalArgumentException) {

View file

@ -34,6 +34,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.core.content.IntentCompat
@ -129,16 +130,7 @@ class MainActivity : ComponentActivity() {
)
}
CompositionLocalProvider(
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
LocalBarcodeScannerSupported provides true,
LocalNfcScannerSupported provides true,
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
) {
AppCompositionLocals {
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
@ -162,6 +154,52 @@ class MainActivity : ComponentActivity() {
handleIntent(intent)
}
@Composable
private fun AppCompositionLocals(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
LocalBarcodeScannerSupported provides true,
LocalNfcScannerSupported provides true,
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
org.meshtastic.core.ui.util.LocalNodeMapScreenProvider provides
{ destNum, onNavigateUp ->
val vm = koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp ->
val metricsViewModel =
koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel>(key = "metrics-$destNum") {
org.koin.core.parameter.parametersOf(destNum)
}
metricsViewModel.setNodeId(destNum)
org.meshtastic.feature.node.metrics.TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = requestId,
logUuid = logUuid,
onNavigateUp = onNavigateUp,
)
},
org.meshtastic.core.ui.util.LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId ->
val viewModel = koinViewModel<org.meshtastic.feature.map.SharedMapViewModel>()
org.meshtastic.feature.map.MapScreen(
viewModel = viewModel,
onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)
},
content = content,
)
}
@Suppress("NestedBlockDepth")
private fun handleIntent(intent: Intent) {
val appLinkAction = intent.action

View file

@ -18,40 +18,14 @@
package org.meshtastic.app.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -60,28 +34,16 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import co.touchlab.kermit.Logger
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.BuildConfig
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_too_old
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.must_update
import org.meshtastic.core.ui.component.MeshtasticAppShell
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
@ -93,154 +55,20 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerViewModel = koinViewModel()) {
fun MainScreen(uIViewModel: UIViewModel = koinViewModel()) {
val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
LaunchedEffect(uIViewModel) {
uIViewModel.navigationDeepLink.collect { uri ->
val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri)?.let { navKeys ->
backStack.clear()
backStack.addAll(navKeys)
}
}
}
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
AndroidAppVersionCheck(uIViewModel)
val navSuiteType =
NavigationSuiteScaffoldDefaults.navigationSuiteType(
currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true),
)
val currentKey = backStack.lastOrNull()
val topLevelDestination = TopLevelDestination.fromNavKey(currentKey)
// State for determining the connection type icon to display
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
MeshtasticAppShell(
backStack = backStack,
uiViewModel = uIViewModel,
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
) {
NavigationSuiteScaffold(
org.meshtastic.core.ui.component.MeshtasticNavigationSuite(
backStack = backStack,
uiViewModel = uIViewModel,
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = {
TopLevelDestination.entries.forEach { destination ->
val isSelected = destination == topLevelDestination
val isConnectionsRoute = destination == TopLevelDestination.Connections
item(
icon = {
TooltipBox(
positionProvider =
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
if (isConnectionsRoute) {
when (connectionState) {
ConnectionState.Connected -> stringResource(Res.string.connected)
ConnectionState.Connecting -> stringResource(Res.string.connecting)
ConnectionState.DeviceSleep ->
stringResource(Res.string.device_sleeping)
ConnectionState.Disconnected ->
stringResource(Res.string.disconnected)
}
} else {
stringResource(destination.label)
},
)
}
},
state = rememberTooltipState(),
) {
if (isConnectionsRoute) {
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice),
meshActivityFlow = uIViewModel.meshActivity,
colorScheme = colorScheme,
)
} else {
BadgedBox(
badge = {
if (destination == TopLevelDestination.Conversations) {
// Keep track of the last non-zero count for display during exit
// animation
var lastNonZeroCount by remember {
mutableIntStateOf(unreadMessageCount)
}
if (unreadMessageCount > 0) {
lastNonZeroCount = unreadMessageCount
}
AnimatedVisibility(
visible = unreadMessageCount > 0,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
) {
Badge { Text(lastNonZeroCount.toString()) }
}
}
},
) {
Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState ->
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
tint =
if (isSelectedState) {
colorScheme.primary
} else {
LocalContentColor.current
},
)
}
}
}
}
},
selected = isSelected,
label = {
Text(
text = stringResource(destination.label),
modifier =
if (navSuiteType == NavigationSuiteType.ShortNavigationBarCompact) {
Modifier.width(1.dp)
.height(1.dp) // hide on phone - min 1x1 or talkback won't see it.
} else {
Modifier
},
)
},
onClick = {
val isRepress = destination == topLevelDestination
if (isRepress) {
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.Nodes
if (!onNodesList) {
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
TopLevelDestination.Conversations -> {
val onConversationsList = currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
else -> Unit
}
} else {
backStack.navigateTopLevel(destination.route)
}
},
)
}
},
) {
val provider =
entryProvider<NavKey> {
@ -249,14 +77,6 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
backStack = backStack,
scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
onHandleDeepLink = uIViewModel::handleDeepLink,
nodeMapScreen = { destNum, onNavigateUp ->
val vm =
org.koin.compose.viewmodel.koinViewModel<
org.meshtastic.feature.map.node.NodeMapViewModel,
>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
)
mapGraph(backStack)
channelsGraph(backStack)

View file

@ -47,7 +47,7 @@ class NavigationAssemblyTest {
val backStack = rememberNavBackStack(NodesRoutes.NodesGraph)
entryProvider<NavKey> {
contactsGraph(backStack, emptyFlow())
nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow(), nodeMapScreen = { _, _ -> })
nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow())
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)