From 34ec760412476fd25e2aafb5db2d096035658aee Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 18 May 2026 13:52:43 -0500 Subject: [PATCH] fix(map): align with Meshtastic design standards and M3 best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- .../feature/map/component/InlineMap.kt | 5 +++- .../feature/map/component/MapButton.kt | 28 ++++++++++++++----- .../map/component/MaplibreMapContent.kt | 21 ++++++++------ .../feature/map/component/NodeInfoSheet.kt | 7 ++++- .../feature/map/component/TracerouteLayers.kt | 13 +++++---- 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 922af5308..5e4683823 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -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), ) } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt index 26bd8d5ba..c0473afb3 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt @@ -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, + ) + } } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index 6a5b022ee..fb6f4b85f 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -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), ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt index c9880067f..d8d6ba453 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeInfoSheet.kt @@ -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( diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index 22e88f8b1..e1662fb26 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -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), ) } }