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

@ -69,14 +69,24 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
@ -164,32 +174,42 @@ class MainActivity : ComponentActivity() {
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier ->
org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier)
},
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
org.meshtastic.core.ui.util.LocalNodeMapScreenProvider provides
LocalTracerouteMapProvider provides
{ overlay, nodePositions, onMappableCountChanged, modifier ->
org.meshtastic.app.map.traceroute.TracerouteMap(
tracerouteOverlay = overlay,
tracerouteNodePositions = nodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
},
LocalNodeMapScreenProvider provides
{ destNum, onNavigateUp ->
val vm = koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp ->
val metricsViewModel =
koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel> {
org.koin.core.parameter.parametersOf(destNum)
}
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) }
metricsViewModel.setNodeId(destNum)
org.meshtastic.feature.node.metrics.TracerouteMapScreen(
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = requestId,
logUuid = logUuid,
onNavigateUp = onNavigateUp,
)
},
org.meshtastic.core.ui.util.LocalMapMainScreenProvider provides
LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId ->
val viewModel = koinViewModel<org.meshtastic.feature.map.SharedMapViewModel>()
org.meshtastic.feature.map.MapScreen(
val viewModel = koinViewModel<SharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails,

View file

@ -0,0 +1,46 @@
/*
* 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
/**
* A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance
* across both Google and F-Droid flavors.
*/
@Composable
fun MapButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
iconTint: Color? = null,
) {
FilledIconButton(onClick = onClick, modifier = modifier) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor,
)
}
}

View file

@ -0,0 +1,133 @@
/*
* 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.core.resources.Res
import org.meshtastic.core.resources.map_filter
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.LocationDisabled
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
/**
* Shared map controls overlay used by both Google and F-Droid map views. Provides compass, filter button, location
* tracking button, and optional slots for flavor-specific content (map type selector, layers, refresh).
*
* @param onToggleFilterMenu Callback to open/close the filter dropdown.
* @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button typically a
* `DropdownMenu` with filter options.
* @param mapTypeContent Optional composable for a map type selector button + dropdown. Google flavor provides map type
* and custom tile options; F-Droid provides a tile source selector.
* @param layersContent Optional composable for a layers management button.
* @param showRefresh Whether to show a refresh button (e.g., for network map layers).
* @param isRefreshing Whether a refresh is currently in progress.
* @param onRefresh Callback when the refresh button is clicked.
*/
@Suppress("LongParameterList")
@Composable
fun MapControlsOverlay(
onToggleFilterMenu: () -> Unit,
modifier: Modifier = Modifier,
bearing: Float = 0f,
onCompassClick: () -> Unit = {},
followPhoneBearing: Boolean = false,
filterDropdownContent: @Composable () -> Unit = {},
mapTypeContent: @Composable () -> Unit = {},
layersContent: @Composable () -> Unit = {},
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
showRefresh: Boolean = false,
isRefreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
Row(modifier = modifier) {
// Compass
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
// Filter button + dropdown
Box {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleFilterMenu,
)
filterDropdownContent()
}
// Map type selector (flavor-specific)
mapTypeContent()
// Layers button (flavor-specific)
layersContent()
// Refresh button (optional)
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 iconTint =
when {
isFollowing -> MaterialTheme.colorScheme.primary
bearing == 0f -> MaterialTheme.colorScheme.StatusRed
else -> null
}
MapButton(
modifier = Modifier.rotate(-bearing),
icon = MeshtasticIcons.MapCompass,
iconTint = iconTint,
contentDescription = stringResource(Res.string.orient_north),
onClick = onClick,
)
}