feat(map): inline map polish & location permission feedback

- InlineMap: add short name SymbolLayer label above marker
- InlineMap: adaptive zoom (zoom out for imprecise positions >500m)
- MapScreen: show snackbar when tapping location button without
  permission instead of silently doing nothing
- Add map_location_unavailable string resource

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-18 13:09:02 -05:00
parent 305ff2321f
commit 4607c3f223
4 changed files with 33 additions and 3 deletions

View File

@@ -689,6 +689,7 @@ map_empty_state
map_filter map_filter
map_layer_formats map_layer_formats
map_load_error map_load_error
map_location_unavailable
map_node_popup_details map_node_popup_details
map_offline_manager map_offline_manager
map_purge_fail map_purge_fail

View File

@@ -719,6 +719,7 @@
<string name="map_filter">Map Filter\n</string> <string name="map_filter">Map Filter\n</string>
<string name="map_layer_formats">Map layers support .kml, .kmz, or GeoJSON formats.</string> <string name="map_layer_formats">Map layers support .kml, .kmz, or GeoJSON formats.</string>
<string name="map_load_error">Map failed to load</string> <string name="map_load_error">Map failed to load</string>
<string name="map_location_unavailable">Location permission required for tracking</string>
<string name="map_node_popup_details">%1$s&lt;br&gt;Last heard: %2$s&lt;br&gt;Last position: %3$s&lt;br&gt;Battery: %4$s</string> <string name="map_node_popup_details">%1$s&lt;br&gt;Last heard: %2$s&lt;br&gt;Last position: %3$s&lt;br&gt;Battery: %4$s</string>
<string name="map_offline_manager">Offline Manager</string> <string name="map_offline_manager">Offline Manager</string>
<string name="map_purge_fail">SQL Cache purge failed, see logcat for details</string> <string name="map_purge_fail">SQL Cache purge failed, see logcat for details</string>

View File

@@ -51,6 +51,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map import org.meshtastic.core.resources.map
import org.meshtastic.core.resources.map_empty_state import org.meshtastic.core.resources.map_empty_state
import org.meshtastic.core.resources.map_load_error import org.meshtastic.core.resources.map_load_error
import org.meshtastic.core.resources.map_location_unavailable
import org.meshtastic.core.resources.waypoint_deleted import org.meshtastic.core.resources.waypoint_deleted
import org.meshtastic.core.resources.waypoint_sent import org.meshtastic.core.resources.waypoint_sent
import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MainAppBar
@@ -112,6 +113,7 @@ fun MapScreen(
// Snackbar messages for map load error // Snackbar messages for map load error
val mapLoadErrorMsg = stringResource(Res.string.map_load_error) val mapLoadErrorMsg = stringResource(Res.string.map_load_error)
val locationUnavailableMsg = stringResource(Res.string.map_location_unavailable)
val waypointSentMsg = stringResource(Res.string.waypoint_sent) val waypointSentMsg = stringResource(Res.string.waypoint_sent)
val waypointDeletedMsg = stringResource(Res.string.waypoint_deleted) val waypointDeletedMsg = stringResource(Res.string.waypoint_deleted)
@@ -263,7 +265,9 @@ fun MapScreen(
isLocationTrackingEnabled = isLocationTrackingEnabled, isLocationTrackingEnabled = isLocationTrackingEnabled,
isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION, isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION,
onToggleLocationTracking = { onToggleLocationTracking = {
if (!isLocationTrackingEnabled) { if (!locationAvailable) {
scope.launch { snackbarHostState.showSnackbar(locationUnavailableMsg) }
} else if (!isLocationTrackingEnabled) {
// Off → Track with bearing // Off → Track with bearing
bearingUpdate = BearingUpdate.TRACK_LOCATION bearingUpdate = BearingUpdate.TRACK_LOCATION
isLocationTrackingEnabled = true isLocationTrackingEnabled = true

View File

@@ -22,10 +22,14 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.offset
import org.maplibre.compose.expressions.value.SymbolAnchor
import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.SymbolLayer
import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.GestureOptions
import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.MaplibreMap
@@ -44,7 +48,10 @@ import org.meshtastic.feature.map.util.precisionBitsToMeters
import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.meshtastic.feature.map.util.toGeoPositionOrNull
private const val DEFAULT_ZOOM = 15.0 private const val DEFAULT_ZOOM = 15.0
private const val LOW_PRECISION_ZOOM = 12.0
private const val PRECISION_THRESHOLD_METERS = 500
private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f
private const val LABEL_OFFSET = -2f
/** /**
* A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the * A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the
@@ -55,8 +62,12 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val position = node.validPosition ?: return val position = node.validPosition ?: return
val geoPos = toGeoPositionOrNull(position.latitude_i, position.longitude_i) ?: return val geoPos = toGeoPositionOrNull(position.latitude_i, position.longitude_i) ?: return
// 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
key(node.num) { key(node.num) {
val cameraState = rememberCameraState(firstPosition = CameraPosition(target = geoPos, zoom = DEFAULT_ZOOM)) val cameraState = rememberCameraState(firstPosition = CameraPosition(target = geoPos, zoom = zoom))
val nodeFeature = val nodeFeature =
remember(node.num, geoPos) { remember(node.num, geoPos) {
@@ -82,8 +93,21 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
strokeColor = const(Color.White), strokeColor = const(Color.White),
) )
// Short name label above the marker
val shortName = node.user.short_name
if (!shortName.isNullOrBlank()) {
SymbolLayer(
id = "inline-node-label",
source = source,
textField = const(shortName).cast(),
textSize = const(0.9f.em),
textOffset = offset(0f.em, LABEL_OFFSET.em),
textAnchor = const(SymbolAnchor.Bottom),
textColor = const(Color.DarkGray),
)
}
// Precision circle — radius computed from precision_meters using latitude-aware metersPerDp // Precision circle — radius computed from precision_meters using latitude-aware metersPerDp
val precisionMeters = precisionBitsToMeters(position.precision_bits)
val metersPerDp = cameraState.metersPerDpAtTarget val metersPerDp = cameraState.metersPerDpAtTarget
if (precisionMeters > 0 && metersPerDp > 0) { if (precisionMeters > 0 && metersPerDp > 0) {
val radiusDp = (precisionMeters / metersPerDp).dp val radiusDp = (precisionMeters / metersPerDp).dp