fix(map): align with Meshtastic design standards and M3 best practices

- Replace hardcoded hex colors with MaterialTheme.colorScheme tokens
  (primary, onPrimary, onSurfaceVariant, surface) so map layers respect
  light/dark mode transitions
- Add TooltipBox with PlainTooltip to MapButton for desktop hover
  accessibility (design standard §4: tooltips for icon-only buttons)
- Set explicit containerColor and scrimColor on NodeInfoSheet's
  ModalBottomSheet for M3 compliance
- Import MaterialTheme in MaplibreMapContent, TracerouteLayers, and
  InlineMap to read semantic color tokens in composable scope

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-18 13:52:43 -05:00
parent 883bb6ae48
commit 34ec760412
5 changed files with 52 additions and 22 deletions

View File

@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.map.component
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
@@ -62,6 +63,8 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val position = node.validPosition ?: return
val geoPos = toGeoPositionOrNull(position.latitude_i, position.longitude_i) ?: return
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
// Adaptive zoom: zoom out for imprecise positions so the precision circle is visible
val precisionMeters = precisionBitsToMeters(position.precision_bits)
val zoom = if (precisionMeters > PRECISION_THRESHOLD_METERS) LOW_PRECISION_ZOOM else DEFAULT_ZOOM
@@ -103,7 +106,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
textSize = const(0.9f.em),
textOffset = offset(0f.em, LABEL_OFFSET.em),
textAnchor = const(SymbolAnchor.Bottom),
textColor = const(Color.DarkGray),
textColor = const(labelColor),
)
}

View File

@@ -16,15 +16,23 @@
*/
package org.meshtastic.feature.map.component
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
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.rememberTooltipState
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. */
/** A compact icon button used in map control overlays. Uses [FilledIconButton] with a hover tooltip for desktop. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun MapButton(
icon: ImageVector,
@@ -33,11 +41,17 @@ internal fun MapButton(
modifier: Modifier = Modifier,
iconTint: Color? = null,
) {
FilledIconButton(onClick = onClick, modifier = modifier) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor,
)
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text(contentDescription) } },
state = rememberTooltipState(),
) {
FilledIconButton(onClick = onClick, modifier = modifier) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor,
)
}
}
}

View File

@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.map.component
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -74,7 +75,6 @@ import org.meshtastic.feature.map.util.nodesToFeatureCollection
import org.meshtastic.feature.map.util.waypointsToFeatureCollection
import org.maplibre.spatialk.geojson.Position as GeoPosition
private val NodeMarkerColor = Color(0xFF6750A4)
private val OnlineStrokeColor = Color(0xFF4CAF50) // Green — node heard within online threshold
private val OfflineStrokeColor = Color(0xFF9E9E9E) // Gray — node not heard recently
private const val CLUSTER_RADIUS = 50
@@ -194,6 +194,11 @@ private fun NodeMarkerLayers(
val coroutineScope = rememberCoroutineScope()
val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) }
// Read M3 semantic colors for map layers (recomposes on theme change)
val clusterColor = MaterialTheme.colorScheme.primary
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
val clusterLabelColor = MaterialTheme.colorScheme.onPrimary
val nodesSource =
rememberGeoJsonSource(
data = GeoJsonData.Features(featureCollection),
@@ -207,10 +212,10 @@ private fun NodeMarkerLayers(
source = nodesSource,
filter = feature.has("cluster"),
radius = const(20.dp),
color = const(NodeMarkerColor), // Material primary
color = const(clusterColor),
opacity = const(CLUSTER_OPACITY),
strokeWidth = const(MARKER_STROKE_WIDTH),
strokeColor = const(Color.White),
strokeColor = const(clusterLabelColor),
onClick = { features ->
val cluster = features.firstOrNull() ?: return@CircleLayer ClickResult.Pass
val target = (cluster.geometry as? Point)?.coordinates ?: return@CircleLayer ClickResult.Pass
@@ -232,7 +237,7 @@ private fun NodeMarkerLayers(
source = nodesSource,
filter = feature.has("cluster"),
textField = feature["point_count"].asString(),
textColor = const(Color.White),
textColor = const(clusterLabelColor),
textSize = const(1.2f.em),
)
@@ -242,7 +247,7 @@ private fun NodeMarkerLayers(
source = nodesSource,
filter = !feature.has("cluster"),
radius = const(NODE_MARKER_RADIUS),
color = feature["background_color"].convertToColor(const(NodeMarkerColor)),
color = feature["background_color"].convertToColor(const(clusterColor)),
strokeWidth = const(MARKER_STROKE_WIDTH),
strokeColor =
switch(
@@ -279,7 +284,7 @@ private fun NodeMarkerLayers(
),
textSize = const(0.9f.em),
textOffset = offset(0f.em, LABEL_OFFSET_EM.em),
textColor = const(Color.DarkGray),
textColor = const(labelColor),
textAllowOverlap = const(true),
iconAllowOverlap = const(true),
)
@@ -301,13 +306,13 @@ private fun NodeMarkerLayers(
radius = (feature["precision_meters"].convertToNumber(const(0f)) * metersToPixels).dp,
color =
feature["background_color"].convertToColor(
const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)),
const(clusterColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)),
),
opacity = const(PRECISION_CIRCLE_FILL_ALPHA),
strokeWidth = const(1.dp),
strokeColor =
feature["background_color"].convertToColor(
const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)),
const(clusterColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)),
),
strokeOpacity = const(PRECISION_CIRCLE_STROKE_ALPHA),
)

View File

@@ -50,7 +50,12 @@ import org.meshtastic.core.ui.component.SignalInfo
internal fun NodeInfoSheet(node: Node, onDismiss: () -> Unit, onViewDetails: (Int) -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
scrimColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f),
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp)) {
// Node name
Text(

View File

@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.map.component
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -52,9 +53,9 @@ import org.meshtastic.feature.map.util.toGeoPositionOrNull
import org.meshtastic.feature.map.util.typedFeatureCollection
import org.maplibre.spatialk.geojson.Position as GeoPosition
private val ForwardRouteColor = Color(0xFF4CAF50)
private val ReturnRouteColor = Color(0xFFF44336)
private val HopMarkerColor = Color(0xFF9C27B0)
private val ForwardRouteColor = Color(0xFF4CAF50) // Success green — forward path
private val ReturnRouteColor = Color(0xFFF44336) // Error red — return path
private val HopMarkerColor = Color(0xFF9C27B0) // Tertiary purple — hop points
private const val HEX_RADIX = 16
private const val ROUTE_OPACITY = 0.8f
@@ -72,6 +73,8 @@ internal fun TracerouteLayers(
if (overlay == null) return
val unknownNodeName = stringResource(Res.string.unknown)
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
val markerStrokeColor = MaterialTheme.colorScheme.surface
// Build route line features
val routeData =
@@ -123,7 +126,7 @@ internal fun TracerouteLayers(
radius = const(NODE_MARKER_RADIUS),
color = const(HopMarkerColor), // Purple
strokeWidth = const(MARKER_STROKE_WIDTH),
strokeColor = const(Color.White),
strokeColor = const(markerStrokeColor),
)
SymbolLayer(
id = "traceroute-hop-labels",
@@ -131,7 +134,7 @@ internal fun TracerouteLayers(
textField = feature["short_name"].asString(),
textSize = const(1.em),
textOffset = offset(0f.em, -2f.em),
textColor = const(Color.DarkGray),
textColor = const(labelColor),
)
}
}