feat(map): add line gradient, zoom buttons, camera padding

- NodeTrackLayers: replace flat color with lineProgress() gradient
  (faded blue → vivid blue showing position age)
- MapControlsOverlay: add +/- zoom buttons in secondary toolbar
  (improves desktop/accessibility where pinch isn't natural)
- MapScreen: add padding to zoom-to-fit-all camera animation
  to avoid UI controls overlap
- Add lineProgress() expression helper (line-progress MapLibre expr)
- Add MeshtasticIcons.Remove (minus) icon

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-18 13:00:43 -05:00
parent 9054f6f026
commit 2df6df8e67
8 changed files with 121 additions and 52 deletions
+2
View File
@@ -645,6 +645,8 @@ map_type_hybrid
map_type_normal
map_type_satellite
map_type_terrain
map_zoom_in
map_zoom_out
mark_as_read
match_all
match_any
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M240,520Q223,520 211.5,508.5Q200,497 200,480Q200,463 211.5,451.5Q223,440 240,440L720,440Q737,440 748.5,451.5Q760,463 760,480Q760,497 748.5,508.5Q737,520 720,520L240,520Z"/>
</vector>
@@ -675,6 +675,8 @@
<string name="map_type_normal">Normal</string>
<string name="map_type_satellite">Satellite</string>
<string name="map_type_terrain">Terrain</string>
<string name="map_zoom_in">Zoom in</string>
<string name="map_zoom_out">Zoom out</string>
<string name="mark_as_read">Mark as read</string>
<string name="match_all">Match All | Any</string>
<string name="match_any">Match Any | All</string>
@@ -46,6 +46,7 @@ import org.meshtastic.core.resources.ic_qr_code
import org.meshtastic.core.resources.ic_qr_code_2
import org.meshtastic.core.resources.ic_qr_code_scanner
import org.meshtastic.core.resources.ic_refresh
import org.meshtastic.core.resources.ic_remove
import org.meshtastic.core.resources.ic_reply
import org.meshtastic.core.resources.ic_restart_alt
import org.meshtastic.core.resources.ic_restore
@@ -136,3 +137,5 @@ val MeshtasticIcons.BarChart: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_bar_chart)
val MeshtasticIcons.List: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_list)
val MeshtasticIcons.Remove: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_remove)
@@ -17,6 +17,7 @@
package org.meshtastic.feature.map
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
@@ -65,6 +66,8 @@ import org.meshtastic.feature.map.util.toGeoPositionOrNull
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val WAYPOINT_ZOOM = 15.0
private const val MIN_ZOOM = 0.0
private const val MAX_ZOOM = 24.0
private val MAP_OVERLAY_PADDING = 16.dp
/**
@@ -249,7 +252,7 @@ fun MapScreen(
}
}
val bbox = computeBoundingBox(positions) ?: return@MapFilterDropdown
scope.launch { cameraState.animateTo(bbox) }
scope.launch { cameraState.animateTo(bbox, padding = PaddingValues(48.dp)) }
},
)
},
@@ -282,6 +285,20 @@ fun MapScreen(
}
}
},
onZoomIn = {
scope.launch {
cameraState.animateTo(
cameraState.position.copy(zoom = minOf(cameraState.position.zoom + 1.0, MAX_ZOOM)),
)
}
},
onZoomOut = {
scope.launch {
cameraState.animateTo(
cameraState.position.copy(zoom = maxOf(cameraState.position.zoom - 1.0, MIN_ZOOM)),
)
}
},
)
// Scale bar — auto-shows on zoom change, hides after 3 seconds
@@ -17,6 +17,7 @@
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Badge
@@ -28,21 +29,26 @@ import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_zoom_in
import org.meshtastic.core.resources.map_zoom_out
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.ui.icon.Add
import org.meshtastic.core.ui.icon.LocationOn
import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
import org.meshtastic.core.ui.icon.NearMe
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Remove
import org.meshtastic.core.ui.icon.Tune
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import kotlin.math.abs
@@ -63,7 +69,7 @@ import kotlin.math.abs
* @param onRefresh Callback when the refresh button is clicked.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LongMethod")
@Composable
fun MapControlsOverlay(
onToggleFilterMenu: () -> Unit,
@@ -81,68 +87,86 @@ fun MapControlsOverlay(
showRefresh: Boolean = false,
isRefreshing: Boolean = false,
onRefresh: () -> Unit = {},
onZoomIn: () -> Unit = {},
onZoomOut: () -> Unit = {},
) {
HorizontalFloatingToolbar(
expanded = true,
modifier = modifier,
colors = FloatingToolbarDefaults.standardFloatingToolbarColors(),
) {
// Compass
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
Column(modifier = modifier, horizontalAlignment = Alignment.End) {
HorizontalFloatingToolbar(expanded = true, colors = FloatingToolbarDefaults.standardFloatingToolbarColors()) {
// Compass
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
// Filter button + dropdown with badge
Box {
if (activeFilterCount > 0) {
BadgedBox(badge = { Badge { Text(activeFilterCount.toString()) } }) {
// Filter button + dropdown with badge
Box {
if (activeFilterCount > 0) {
BadgedBox(badge = { Badge { Text(activeFilterCount.toString()) } }) {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleFilterMenu,
)
}
} else {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleFilterMenu,
)
}
} else {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleFilterMenu,
)
filterDropdownContent()
}
filterDropdownContent()
}
// Map type selector (flavor-specific)
mapTypeContent()
// Map type selector (flavor-specific)
mapTypeContent()
// Layers button (flavor-specific)
layersContent()
// Layers button (flavor-specific)
layersContent()
// Refresh button (optional)
if (showRefresh) {
if (isRefreshing) {
Box(modifier = Modifier.padding(8.dp)) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
// Refresh button (optional)
if (showRefresh) {
if (isRefreshing) {
Box(modifier = Modifier.padding(8.dp)) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
}
} else {
MapButton(
icon = MeshtasticIcons.Refresh,
contentDescription = stringResource(Res.string.refresh),
onClick = onRefresh,
)
}
} else {
MapButton(
icon = MeshtasticIcons.Refresh,
contentDescription = stringResource(Res.string.refresh),
onClick = onRefresh,
)
}
// Location tracking button — 3 states: Off (MyLocation), Tracking (NearMe), TrackingNorth (LocationOn)
MapButton(
icon =
when {
!isLocationTrackingEnabled -> MeshtasticIcons.MyLocation
isTrackingBearing -> MeshtasticIcons.NearMe
else -> MeshtasticIcons.LocationOn
},
contentDescription = stringResource(Res.string.toggle_my_position),
iconTint = if (isLocationTrackingEnabled) MaterialTheme.colorScheme.primary else null,
onClick = onToggleLocationTracking,
)
}
// Location tracking button — 3 states: Off (MyLocation), Tracking (NearMe), TrackingNorth (LocationOn)
MapButton(
icon =
when {
!isLocationTrackingEnabled -> MeshtasticIcons.MyLocation
isTrackingBearing -> MeshtasticIcons.NearMe
else -> MeshtasticIcons.LocationOn
},
contentDescription = stringResource(Res.string.toggle_my_position),
iconTint = if (isLocationTrackingEnabled) MaterialTheme.colorScheme.primary else null,
onClick = onToggleLocationTracking,
)
// Zoom buttons (useful for desktop/accessibility where pinch isn't natural)
HorizontalFloatingToolbar(
expanded = true,
modifier = Modifier.padding(top = 4.dp),
colors = FloatingToolbarDefaults.standardFloatingToolbarColors(),
) {
MapButton(
icon = MeshtasticIcons.Add,
contentDescription = stringResource(Res.string.map_zoom_in),
onClick = onZoomIn,
)
MapButton(
icon = MeshtasticIcons.Remove,
contentDescription = stringResource(Res.string.map_zoom_out),
onClick = onZoomOut,
)
}
}
}
@@ -25,6 +25,8 @@ import org.maplibre.compose.expressions.dsl.asString
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.eq
import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.interpolate
import org.maplibre.compose.expressions.dsl.linear
import org.maplibre.compose.expressions.value.LineCap
import org.maplibre.compose.expressions.value.LineJoin
import org.maplibre.compose.layers.CircleLayer
@@ -33,12 +35,13 @@ import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.GeoJsonOptions
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.util.ClickResult
import org.meshtastic.feature.map.util.lineProgress
import org.meshtastic.feature.map.util.positionsToLineString
import org.meshtastic.feature.map.util.positionsToPointFeatures
private val TrackColor = Color(0xFF2196F3)
private val TrackColorFaded = Color(0x662196F3)
private val SelectedPointColor = Color(0xFFF44336)
private const val TRACK_OPACITY = 0.8f
private const val SELECTED_OPACITY = 0.9f
/**
@@ -62,13 +65,12 @@ internal fun NodeTrackLayers(
options = GeoJsonOptions(lineMetrics = true),
)
// Track line with gradient
// Track line with gradient (oldest positions faded → newest positions vivid)
LineLayer(
id = "node-track-line",
source = lineSource,
width = const(3.dp),
color = const(TrackColor), // Blue
opacity = const(TRACK_OPACITY),
gradient = interpolate(linear(), lineProgress(), 0 to const(TrackColorFaded), 1 to const(TrackColor)),
cap = const(LineCap.Round),
join = const(LineJoin.Round),
)
@@ -18,6 +18,9 @@ package org.meshtastic.feature.map.util
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.maplibre.compose.expressions.ast.Expression
import org.maplibre.compose.expressions.ast.FunctionCall
import org.maplibre.compose.expressions.value.FloatValue
import org.maplibre.spatialk.geojson.BoundingBox
import org.maplibre.spatialk.geojson.Position as GeoPosition
@@ -57,3 +60,10 @@ internal fun computeBoundingBox(positions: List<GeoPosition>): BoundingBox? {
northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()),
)
}
/**
* Gets the progress along a line feature, from 0 at the start to 1 at the end. Can only be used with GeoJSON sources
* that specify `lineMetrics = true`. Use with [interpolate][org.maplibre.compose.expressions.dsl.interpolate] to create
* gradient colors.
*/
internal fun lineProgress(): Expression<FloatValue> = FunctionCall.of("line-progress").cast()