From b12ca979c8eee48947d7d9b5fe0d9fc4f83a4d2d Mon Sep 17 00:00:00 2001 From: Tha_14 Date: Tue, 29 Sep 2026 10:45:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(map-maplibre):=20start=20with=20collapsed?= =?UTF-8?q?=20attribution=20strip=20on=20seconda=E2=80=A6=20(#7424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meshtastic/app/map/node/NodeTrackMap.kt | 2 + .../meshtastic/app/map/node/NodeTrackMap.kt | 2 + .../kotlin/org/meshtastic/app/MainActivity.kt | 3 +- core/ui/detekt-baseline.xml | 2 +- .../core/ui/util/LocalNodeTrackMapProvider.kt | 5 +- .../kotlin/org/meshtastic/desktop/Main.kt | 3 +- .../feature/map/maplibre/NodeTrackMap.kt | 7 +- .../map/maplibre/SecondaryMapScaffold.kt | 9 ++ .../feature/map/maplibre/SecondaryMaps.kt | 13 +- .../map/maplibre/component/MapOrnaments.kt | 115 +++++++++++++++++- .../map/maplibre/component/ZoomControls.kt | 7 +- .../node/metrics/PositionLogScreens.kt | 4 +- 12 files changed, 155 insertions(+), 17 deletions(-) diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt index d613978c50..c06269f426 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -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() }, ) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt index 21b52b154a..034f316ac5 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt @@ -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() vm.setDestNum(destNum) diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 02c783f202..4036bca174 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -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(), diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml index cacd9cf0b6..fd2d325511 100644 --- a/core/ui/detekt-baseline.xml +++ b/core/ui/detekt-baseline.xml @@ -24,7 +24,7 @@ CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerSupported = compositionLocalOf { false } CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcWriterProvider = compositionLocalOf<@Composable (url: String, onResult: (Boolean) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _, _ -> } } 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<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> { { destNum, _ -> PlaceholderScreen("Node Map ($destNum)") } } - 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< @Composable ( destNum: Int, positions: List<Position>, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -> Unit)?, ) -> Unit, > { { _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } } + 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< @Composable ( destNum: Int, positions: List<Position>, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -> Unit)?, showAttribution: Boolean, ) -> Unit, > { { _, _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } } CompositionLocalAllowlist:LocalTracerouteMapOverlayInsetsProvider.kt:val LocalTracerouteMapOverlayInsetsProvider = compositionLocalOf { TracerouteMapOverlayInsets() } 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< @Composable ( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map<Int, Position>, onMappableCountChanged: (Int, Int) -> Unit, modifier: Modifier, ) -> Unit, > { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } } 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<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt index d0901f0f9d..8319ec11e5 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt @@ -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") } } diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt index 7bf79e2c89..ec16dbed16 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -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 diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt index 3791849329..9b63404e97 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt @@ -92,6 +92,7 @@ fun MapLibreNodeTrackMap( modifier: Modifier = Modifier, selectedPositionTime: Int? = null, onPositionSelect: ((Int) -> Unit)? = null, + showAttribution: Boolean = true, customBasemaps: @Composable () -> List = { 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), diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMapScaffold.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMapScaffold.kt index 55511f530f..0cae86f855 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMapScaffold.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMapScaffold.kt @@ -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) }, ) } diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt index 4c8909c165..11878bfb09 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt @@ -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]. */ diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt index 305e85f0eb..a5fdd27c81 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt @@ -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 } + } + } +} diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ZoomControls.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ZoomControls.kt index 6e86c9f088..e14a604791 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ZoomControls.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/ZoomControls.kt @@ -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) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt index 66248d19d9..530ad52e9d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -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) {