diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 482263a25..61da77ce0 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -689,6 +689,7 @@ map_empty_state map_filter map_layer_formats map_load_error +map_location_unavailable map_node_popup_details map_offline_manager map_purge_fail diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 641530db5..2ad87a94b 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -719,6 +719,7 @@ Map Filter\n Map layers support .kml, .kmz, or GeoJSON formats. Map failed to load + Location permission required for tracking %1$s<br>Last heard: %2$s<br>Last position: %3$s<br>Battery: %4$s Offline Manager SQL Cache purge failed, see logcat for details diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index def0b1cc0..a8a20eabc 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -51,6 +51,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map import org.meshtastic.core.resources.map_empty_state 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_sent import org.meshtastic.core.ui.component.MainAppBar @@ -112,6 +113,7 @@ fun MapScreen( // Snackbar messages for 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 waypointDeletedMsg = stringResource(Res.string.waypoint_deleted) @@ -263,7 +265,9 @@ fun MapScreen( isLocationTrackingEnabled = isLocationTrackingEnabled, isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION, onToggleLocationTracking = { - if (!isLocationTrackingEnabled) { + if (!locationAvailable) { + scope.launch { snackbarHostState.showSnackbar(locationUnavailableMsg) } + } else if (!isLocationTrackingEnabled) { // Off → Track with bearing bearingUpdate = BearingUpdate.TRACK_LOCATION isLocationTrackingEnabled = true 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 8f555895d..922af5308 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 @@ -22,10 +22,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.rememberCameraState 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.SymbolLayer import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap @@ -44,7 +48,10 @@ import org.meshtastic.feature.map.util.precisionBitsToMeters import org.meshtastic.feature.map.util.toGeoPositionOrNull 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 LABEL_OFFSET = -2f /** * 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 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) { - val cameraState = rememberCameraState(firstPosition = CameraPosition(target = geoPos, zoom = DEFAULT_ZOOM)) + val cameraState = rememberCameraState(firstPosition = CameraPosition(target = geoPos, zoom = zoom)) val nodeFeature = remember(node.num, geoPos) { @@ -82,8 +93,21 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { 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 - val precisionMeters = precisionBitsToMeters(position.precision_bits) val metersPerDp = cameraState.metersPerDpAtTarget if (precisionMeters > 0 && metersPerDp > 0) { val radiusDp = (precisionMeters / metersPerDp).dp