feat(map-maplibre): start with collapsed attribution strip on seconda… (#7424)

This commit is contained in:
Tha_14 authored and GitHub committed 2026-09-29 10:45:34 +00:00
1 parent 6279d660d3
commit b12ca979c8
12 files changed
+155 -17

No files matched your search

@@ -35,11 +35,13 @@ fun NodeTrackMap(
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelect: ((Int) -> Unit)? = null,
showAttribution: Boolean = true,
) = MapLibreNodeTrackMap(
destNum = destNum,
positions = positions,
modifier = modifier,
selectedPositionTime = selectedPositionTime,
onPositionSelect = onPositionSelect,
showAttribution = showAttribution,
customBasemaps = { androidCustomRasterBasemaps() },
)
@@ -41,6 +41,8 @@ fun NodeTrackMap(
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelect: ((Int) -> Unit)? = null,
// Accepted for the shared seam and ignored: Google Maps draws its own attribution, in MapView.
@Suppress("UNUSED_PARAMETER") showAttribution: Boolean = true,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
@@ -259,13 +259,14 @@ class MainActivity : AppCompatActivity() {
LocalSitePlannerAvailable provides true,
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
{ destNum, positions, modifier, selectedPositionTime, onPositionSelected, showAttribution ->
org.meshtastic.app.map.node.NodeTrackMap(
destNum,
positions,
modifier,
selectedPositionTime,
onPositionSelected,
showAttribution,
)
},
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
+1 -1
View File
@@ -24,7 +24,7 @@
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerSupported = compositionLocalOf { false }</ID>
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcWriterProvider = compositionLocalOf&lt;@Composable (url: String, onResult: (Boolean) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _, _ -> } }</ID>
<ID>CompositionLocalAllowlist:LocalNodeMapScreenProvider.kt:/** * Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM * targets where native maps aren't available yet, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeMapScreenProvider = compositionLocalOf&lt;@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> { { destNum, _ -> PlaceholderScreen("Node Map ($destNum)") } }</ID>
<ID>CompositionLocalAllowlist:LocalNodeTrackMapProvider.kt:/** * Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions]. * Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded * inside another screen layout (e.g. the position-log adaptive layout). * * Supports optional synchronized selection: * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When * non-null, the map should visually highlight the corresponding marker and center the camera on it. * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so * the host can synchronize the card list. * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeTrackMapProvider = compositionLocalOf&lt; @Composable ( destNum: Int, positions: List&lt;Position>, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -> Unit)?, ) -> Unit, > { { _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } }</ID>
<ID>CompositionLocalAllowlist:LocalNodeTrackMapProvider.kt:/** * Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions]. * Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded * inside another screen layout (e.g. the position-log adaptive layout). * * Supports optional synchronized selection: * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When * non-null, the map should visually highlight the corresponding marker and center the camera on it. * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so * the host can synchronize the card list. * - [showAttribution]: whether the credit opens with the map or stays collapsed behind its own button. The embedded * instance collapses it; a full-screen one does not. * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeTrackMapProvider = compositionLocalOf&lt; @Composable ( destNum: Int, positions: List&lt;Position&gt;, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -&gt; Unit)?, showAttribution: Boolean, ) -&gt; Unit, &gt; { { _, _, _, _, _, _ -&gt; PlaceholderScreen("Position Track Map") } }</ID>
<ID>CompositionLocalAllowlist:LocalTracerouteMapOverlayInsetsProvider.kt:val LocalTracerouteMapOverlayInsetsProvider = compositionLocalOf { TracerouteMapOverlayInsets() }</ID>
<ID>CompositionLocalAllowlist:LocalTracerouteMapProvider.kt:/** * Provides an embeddable traceroute map composable that renders node markers and forward/return offset polylines for a * traceroute result. Unlike [LocalMapViewProvider], this does **not** include a Scaffold, AppBar, waypoints, location * tracking, custom tiles, or any main-map features — it is designed to be embedded inside `TracerouteMapScreen`'s * scaffold. * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. * * Parameters: * - `tracerouteOverlay`: The overlay with forward/return route node nums. * - `tracerouteNodePositions`: Map of node num to position snapshots for the route nodes. * - `onMappableCountChanged`: Callback with (shown, total) node counts. * - `modifier`: Compose modifier for the map. */ @Suppress("Wrapping") val LocalTracerouteMapProvider = compositionLocalOf&lt; @Composable ( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map&lt;Int, Position>, onMappableCountChanged: (Int, Int) -> Unit, modifier: Modifier, ) -> Unit, > { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } }</ID>
<ID>CompositionLocalAllowlist:LocalTracerouteMapScreenProvider.kt:/** * Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available * yet, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalTracerouteMapScreenProvider = compositionLocalOf&lt;@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } }</ID>
@@ -32,6 +32,8 @@ import org.meshtastic.proto.Position
* non-null, the map should visually highlight the corresponding marker and center the camera on it.
* - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so
* the host can synchronize the card list.
* - [showAttribution]: whether the credit opens with the map or stays collapsed behind its own button. The embedded
* instance collapses it; a full-screen one does not.
*
* On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
*/
@@ -44,7 +46,8 @@ val LocalNodeTrackMapProvider =
modifier: Modifier,
selectedPositionTime: Int?,
onPositionSelected: ((Int) -> Unit)?,
showAttribution: Boolean,
) -> Unit,
> {
{ _, _, _, _, _ -> PlaceholderScreen("Position Track Map") }
{ _, _, _, _, _, _ -> PlaceholderScreen("Position Track Map") }
}
@@ -456,13 +456,14 @@ private fun ApplicationScope.MeshtasticWindow(
},
LocalInlineMapProvider provides { node, modifier -> MapLibreInlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier, selectedPositionTime, onPositionSelect ->
{ destNum, positions, modifier, selectedPositionTime, onPositionSelect, showAttribution ->
MapLibreNodeTrackMap(
destNum = destNum,
positions = positions,
modifier = modifier,
selectedPositionTime = selectedPositionTime,
onPositionSelect = onPositionSelect,
showAttribution = showAttribution,
)
},
LocalDiscoveryMapProvider provides
@@ -92,6 +92,7 @@ fun MapLibreNodeTrackMap(
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelect: ((Int) -> Unit)? = null,
showAttribution: Boolean = true,
customBasemaps: @Composable () -> List<Basemap.Raster> = { customRasterBasemaps() },
) {
// Oldest first, as the Google flavor sorts its own track. Everything downstream reads order as age: the gradient
@@ -141,7 +142,7 @@ fun MapLibreNodeTrackMap(
// The map and its toolbar stay up even when the filter empties the track — otherwise the control that emptied it
// disappears along with the points, leaving no way back.
Box(modifier = modifier) {
SecondaryMapSurface(mapState = mapState, basemaps = basemaps)
SecondaryMapSurface(mapState = mapState, basemaps = basemaps, showAttribution = showAttribution)
SecondaryMapChrome(
mapState = mapState,
basemaps = basemaps,
@@ -168,8 +169,8 @@ fun MapLibreNodeTrackMap(
?.takeIf { selected -> points.any { it.second == selected } }
?.let { selected -> positions.firstOrNull { it.time == selected } },
displayUnits = displayUnits,
// Clear of the logo and attribution along the bottom edge, which the styles are licensed on condition of
// showing. See MeshMapOrnaments.
// Clear of the wordmark and attribution button along the bottom edge, which every map shows.
// See MeshMapOrnaments.
modifier =
Modifier.align(Alignment.BottomStart)
.padding(start = CARD_INSET.dp, end = CARD_INSET.dp, bottom = ORNAMENT_CLEARANCE.dp),
@@ -35,15 +35,18 @@ import org.maplibre.compose.map.MapState
import org.maplibre.compose.map.MapUiOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.rememberMapState
import org.maplibre.compose.overlay.include
import org.maplibre.spatialk.geojson.BoundingBox
import org.meshtastic.feature.map.component.MapEngineUnavailable
import org.meshtastic.feature.map.maplibre.component.BasemapSelection
import org.meshtastic.feature.map.maplibre.component.CollapsedAttributionOrnaments
import org.meshtastic.feature.map.maplibre.component.MapZoom
import org.meshtastic.feature.map.maplibre.component.SecondaryMapControls
import org.meshtastic.feature.map.maplibre.layers.RasterBasemapLayer
import org.meshtastic.feature.map.maplibre.style.Basemap
import org.meshtastic.feature.map.maplibre.style.toBaseStyle
import org.meshtastic.feature.map.maplibre.style.zoomRange
import org.maplibre.compose.overlay.MapOverlay as MaplibreOverlay
/**
* The map state the maps outside the main one all share.
@@ -83,6 +86,9 @@ internal fun rememberSecondaryMapState(
*
* [basemaps] must be the same selection that state was built with: it supplies the zoom range the camera is held to,
* and a different value here would clamp the camera to a range the loaded style cannot serve.
*
* [showAttribution] picks between the credit open and the credit collapsed; both keep the wordmark and the button that
* reveals it. See [CollapsedAttributionOrnaments].
*/
@Composable
internal fun SecondaryMapSurface(
@@ -91,6 +97,7 @@ internal fun SecondaryMapSurface(
modifier: Modifier = Modifier.fillMaxSize(),
interactions: MapInteractions = SecondaryMapInteractions,
uiOptions: MapUiOptions = MapUiOptions.Standard,
showAttribution: Boolean = true,
) {
// Same guard as MeshMap, and in the same place: the style content is never composed without a presentation, so
// it stops here too. Like MeshMap's, it covers the view and not the state.
@@ -105,6 +112,8 @@ internal fun SecondaryMapSurface(
CameraConstraints(minZoom = zoomRange.start.toDouble(), maxZoom = zoomRange.endInclusive.toDouble()),
interactions = interactions,
uiOptions = uiOptions,
// The library's default, spelled out so the collapsed branch stays a narrowing of it.
overlay = { include(if (showAttribution) MaplibreOverlay.Default else CollapsedAttributionOrnaments) },
)
}
@@ -130,7 +130,12 @@ fun MapLibreInlineMap(
// The same control every other map uses, at the same size. A shrunken variant was tried and looked out of place
// against the rest of the map chrome for the ~25% of height it saved.
Box(modifier = modifier) {
SecondaryMapSurface(mapState = mapState, basemaps = basemaps, uiOptions = InlineMapUiOptions)
SecondaryMapSurface(
mapState = mapState,
basemaps = basemaps,
uiOptions = InlineMapUiOptions,
showAttribution = false,
)
MapZoom(mapState = mapState, basemap = basemaps.current)
}
@@ -166,7 +171,7 @@ fun MapLibreTracerouteMap(
FitBoundsOnceVisible(mapState = mapState, key = hops) { nodesBoundingBox(hops) }
Box(modifier = modifier) {
SecondaryMapSurface(mapState = mapState, basemaps = basemaps)
SecondaryMapSurface(mapState = mapState, basemaps = basemaps, showAttribution = false)
SecondaryMapChrome(mapState = mapState, basemaps = basemaps)
}
}
@@ -226,7 +231,7 @@ fun MapLibreDiscoveryMap(
// snippet, so the tapped node's numbers go at the foot of the map.
DiscoveryNodeCard(
node = selectedNode,
// Clear of the logo and attribution row, which the styles are licensed on condition of showing.
// Clear of the wordmark and attribution button along the bottom edge, which every map shows.
modifier =
Modifier.align(Alignment.BottomStart)
.padding(start = CARD_INSET.dp, end = CARD_INSET.dp, bottom = ORNAMENT_CLEARANCE.dp),
@@ -442,7 +447,7 @@ internal val SecondaryMapFitPadding = PaddingValues(start = 48.dp, top = 64.dp,
internal const val CARD_INSET = 8
/**
* Height to leave for the map's logo and attribution row, which the styles are licensed on condition of showing.
* Height to leave for the map's wordmark and attribution button, which every map shows.
*
* See [org.meshtastic.feature.map.maplibre.component.MeshMapOrnaments].
*/
@@ -16,30 +16,56 @@
*/
package org.meshtastic.feature.map.maplibre.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.filter
import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.map.LocalMapState
import org.maplibre.compose.overlay.AttributionDefaults
import org.maplibre.compose.overlay.AttributionLinks
import org.maplibre.compose.overlay.DisappearingScaleBar
import org.maplibre.compose.overlay.LocalViewportInsets
import org.maplibre.compose.overlay.MaplibreLogo
import org.maplibre.compose.overlay.attributions
import org.maplibre.compose.overlay.include
import org.maplibre.compose.overlay.MapOverlay as MaplibreOverlay
/**
* The map's own ornaments: a scale bar while zooming, and the logo and attribution along the bottom.
*
* This is `MapOverlay.Default` with its compass removed. The mesh map already has a compass in its toolbar — one that
* also toggles heading-lock — and drawing the library's as well would put two compasses on screen. The Google flavor
* This reproduces `MapOverlay.Default`, which is a scale bar plus `AttributionOnly`, and is spelled out rather than
* `include`d because the library's inset helper is internal to it.
*
* The library's own compass and zoom pair are absent by choice rather than by removal: the mesh map already has a
* compass in its toolbar — one that also toggles heading-lock — and draws its zoom pair as `MapZoom`. The Google flavor
* makes the same call from the other direction with `compassEnabled = false`.
*
* The logo and attribution are deliberately kept: the styles this map serves are licensed on the condition that they
* are shown. Do not replace this with `MapOverlay.None`.
* are shown. Do not replace this with `MapOverlay.None`; the main map is where the credit is read, not hidden behind a
* tap.
*
* Scale-bar units are left to the library, which picks them by region — the same locale-driven approach the rest of the
* app takes through `localeUnitsProvider`.
@@ -68,3 +94,86 @@ internal val MeshMapOrnaments: MaplibreOverlay = MaplibreOverlay {
// The logo and the attribution button, in the places `MapOverlay.Default` puts them.
include(MaplibreOverlay.AttributionOnly)
}
/**
* `MapOverlay.Default` with the credit collapsed, for a map too small to host the credit without it overflowing: the
* scale bar, the wordmark and the button all stay, and the button is what reveals the text.
*
* Composed rather than reached for because the library cannot be asked for it — [CollapsedAttributionButton] says why —
* and the insets are spelled out because its `DefaultControls` is internal to it.
*/
internal val CollapsedAttributionOrnaments: MaplibreOverlay = MaplibreOverlay {
val mapState = checkNotNull(LocalMapState.current)
val viewportInsets = LocalViewportInsets.current
Box(
modifier =
Modifier.fillMaxSize()
.padding(viewportInsets)
.consumeWindowInsets(viewportInsets)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(MaplibreOverlay.Spacing),
) {
DisappearingScaleBar(
metersPerDp = { mapState.viewport?.metersPerDpAtTarget ?: 0.0 },
zoom = { mapState.cameraPosition.zoom },
modifier = Modifier.align(Alignment.TopStart),
)
MaplibreLogo(Modifier.align(Alignment.BottomStart))
CollapsedAttributionButton(Modifier.align(Alignment.BottomEnd))
}
}
/**
* How much of the map's width the expanded credit may take, the rest going to the toggle. Under half, so the strip
* still reads as a strip on a thumbnail rather than a banner, and the map beside it stays usable.
*/
private const val CREDIT_WIDTH_FRACTION = 0.55f
/**
* The library's attribution button, starting collapsed.
*
* `ExpandingAttributionButton` holds its own `expanded` flag, initialised to `true` and not settable from outside, so
* `collapsedStyle` only styles the branch it is already in and the credit still opens. Holding the flag here is what
* makes it start shut; everything else — icon, label, links — still comes from the library.
*/
@Composable
private fun CollapsedAttributionButton(modifier: Modifier = Modifier) {
val mapState = checkNotNull(LocalMapState.current)
val mapStyle = mapState.style
// Derived, not remembered: a basemap arrives as a style URL, so its sources are still empty on the first
// composition and a remembered list would never re-read. As in the library's own button.
val attributions by remember(mapStyle) { derivedStateOf { mapStyle.attributions() } }
if (attributions.isEmpty()) return
var expanded by remember(mapStyle) { mutableStateOf(false) }
// As the library's button: a gesture closes the credit, so it does not sit over a short map after a pan. Narrowed
// to gestures, or the programmatic moves our own zoom controls make would fold it away mid-animation.
LaunchedEffect(mapState) {
snapshotFlow { mapState.isCameraMoving && mapState.cameraMoveReason == CameraMoveReason.GESTURE }
.filter { it }
.collect { expanded = false }
}
BoxWithConstraints(modifier.clip(RoundedCornerShape(24.dp)).background(AttributionDefaults.ContainerColor)) {
// Read out here, not inside AnimatedVisibility: that lambda's receiver hides this scope.
val creditMaxWidth = maxWidth * CREDIT_WIDTH_FRACTION
Row(verticalAlignment = Alignment.CenterVertically) {
// The toggle is last: the container is trailing-aligned, so a leading toggle would sit inboard of the
// credit
// and drift inwards as it opens. A Row mirrors itself, so last is the trailing edge in RTL too.
AnimatedVisibility(visible = expanded) {
Box(
Modifier
// `AttributionLinks` scrolls its own single line, so it only needs a ceiling: unbounded it
// measures at the credit's full width and sprawls past a short map.
.widthIn(max = creditMaxWidth)
.padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
) {
AttributionLinks(attributions = attributions, textStyle = AttributionDefaults.ContentTextStyle)
}
}
AttributionDefaults.button { expanded = !expanded }
}
}
}
@@ -37,6 +37,9 @@ private const val ZOOM_INSET = 8
/**
* Lift above the attribution button, which is 40dp of icon plus its pill padding and shares this corner. More than the
* cards need on the other side, where only the 23dp wordmark sits.
*
* The button is there either way — the small maps only collapse its credit, see [CollapsedAttributionOrnaments] — so
* this clears it whether it is expanded or not.
*/
private const val ZOOM_BOTTOM_INSET = 56
@@ -47,8 +50,8 @@ private const val ZOOM_BOTTOM_INSET = 56
* extension because the placement is the point: a caller that had to align it itself would eventually align it
* somewhere else.
*
* Lifted clear of the logo and attribution row along the bottom edge, which the styles are licensed on condition of
* showing — see [MeshMapOrnaments].
* Lifted clear of the wordmark and attribution button along the bottom edge, which every map shows. See
* [MeshMapOrnaments] for why the main map carries the credit outright and the small maps behind the button.
*/
@Composable
internal fun BoxScope.MapZoom(mapState: MapState, basemap: Basemap) {
@@ -81,7 +81,9 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
},
chartPart = { modifier, selectedX, _, onPointSelected ->
val selectedTime = selectedX?.toInt()
trackMap(destNum, positions, modifier, selectedTime) { time -> onPointSelected(time.toDouble()) }
// Positional: trackMap is a function type, so it takes no named arguments. Collapsed credit, this being a
// strip inside a screen whose own map is a tab away.
trackMap(destNum, positions, modifier, selectedTime, { time -> onPointSelected(time.toDouble()) }, false)
},
listPart = { modifier, selectedX, lazyListState, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {