refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)

This commit is contained in:
James Rich 2026-04-10 15:54:09 -05:00 committed by GitHub
parent 56332f4d77
commit 520fa717a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 3464 additions and 2169 deletions

View file

@ -23,31 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
/** Google Maps implementation of [MapViewProvider]. */
@Single
class GoogleMapViewProvider : MapViewProvider {
@Composable
override fun MapView(
modifier: Modifier,
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int?,
nodeTracks: List<Any>?,
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
waypointId: Int?,
) {
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
focusedNodeNum = focusedNodeNum,
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,42 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun MapButton(
modifier: Modifier = Modifier,
icon: ImageVector,
iconTint: Color? = null,
contentDescription: String,
onClick: () -> Unit,
) {
FilledIconButton(onClick = onClick, modifier = modifier) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor,
)
}
}

View file

@ -1,158 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.LocationDisabled
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Tune
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@Composable
fun MapControlsOverlay(
modifier: Modifier = Modifier,
mapFilterMenuExpanded: Boolean,
onMapFilterMenuDismissRequest: () -> Unit,
onToggleMapFilterMenu: () -> Unit,
mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown
mapTypeMenuExpanded: Boolean,
onMapTypeMenuDismissRequest: () -> Unit,
onToggleMapTypeMenu: () -> Unit,
onManageLayersClicked: () -> Unit,
onManageCustomTileProvidersClicked: () -> Unit, // New parameter
isNodeMap: Boolean,
// Location tracking parameters
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
bearing: Float = 0f,
onCompassClick: () -> Unit = {},
followPhoneBearing: Boolean,
showRefresh: Boolean = false,
isRefreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
Row(modifier = modifier) {
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
if (isNodeMap) {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleMapFilterMenu,
)
NodeMapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
} else {
Box {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleMapFilterMenu,
)
MapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
}
}
Box {
MapButton(
icon = MeshtasticIcons.Map,
contentDescription = stringResource(Res.string.map_tile_source),
onClick = onToggleMapTypeMenu,
)
MapTypeDropdown(
expanded = mapTypeMenuExpanded,
onDismissRequest = onMapTypeMenuDismissRequest,
mapViewModel = mapViewModel, // Pass mapViewModel
onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback
)
}
MapButton(
icon = MeshtasticIcons.Layers,
contentDescription = stringResource(Res.string.manage_map_layers),
onClick = onManageLayersClicked,
)
if (showRefresh) {
if (isRefreshing) {
Box(modifier = Modifier.padding(8.dp)) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
}
} else {
MapButton(
icon = MeshtasticIcons.Refresh,
contentDescription = stringResource(Res.string.refresh),
onClick = onRefresh,
)
}
}
// Location tracking button
MapButton(
icon =
if (isLocationTrackingEnabled) {
MeshtasticIcons.LocationDisabled
} else {
MeshtasticIcons.MyLocation
},
contentDescription = stringResource(Res.string.toggle_my_position),
onClick = onToggleLocationTracking,
)
}
}
@Composable
private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) {
val icon = if (isFollowing) MeshtasticIcons.MapCompass else MeshtasticIcons.MapCompass
MapButton(
modifier = Modifier.rotate(-bearing),
icon = icon,
iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { bearing == 0f },
contentDescription = stringResource(Res.string.orient_north),
onClick = onClick,
)
}

View file

@ -42,9 +42,9 @@ import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Place
import org.meshtastic.core.ui.icon.RadioButtonUnchecked
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.feature.map.LastHeardFilter
import kotlin.math.roundToInt
@ -73,7 +73,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Place,
imageVector = MeshtasticIcons.PinDrop,
contentDescription = stringResource(Res.string.show_waypoints),
)
},
@ -89,7 +89,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.RadioButtonUnchecked, // Placeholder icon
imageVector = MeshtasticIcons.Lens,
contentDescription = stringResource(Res.string.show_precision_circle),
)
},

View file

@ -25,14 +25,13 @@ import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
private const val DEG_D = 1e-7
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,

View file

@ -16,13 +16,14 @@
*/
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
@ -31,7 +32,6 @@ import org.meshtastic.feature.map.node.NodeMapViewModel
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
val destNum = node?.num
Scaffold(
topBar = {
@ -46,8 +46,9 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
)
},
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
}
MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.node
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a
* [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode,
* which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track
* filter).
*/
@Composable
fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
val focusedNode by vm.node.collectAsStateWithLifecycle()
MapView(modifier = modifier, mode = GoogleMapMode.NodeTrack(focusedNode = focusedNode, positions = positions))
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.traceroute
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute]
* mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay).
*/
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
MapView(
modifier = modifier,
mode =
GoogleMapMode.Traceroute(
overlay = tracerouteOverlay,
nodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
),
)
}