mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-02 06:24:16 +02:00
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:
Generated
+2
@@ -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
|
||||
|
||||
+71
-47
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-4
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user