mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-30 08:25:43 -04:00
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:
1
.skills/compose-ui/strings-index.txt
generated
1
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||||
|
|||||||
@@ -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<br>Last heard: %2$s<br>Last position: %3$s<br>Battery: %4$s</string>
|
<string name="map_node_popup_details">%1$s<br>Last heard: %2$s<br>Last position: %3$s<br>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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user