Introduce LocationPinMarkers composable
This commit is contained in:
parent
34aad88023
commit
3cce8caec4
7 changed files with 343 additions and 70 deletions
|
|
@ -19,7 +19,6 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
|
@ -32,12 +31,10 @@ import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.features.location.api.internal.StaticMapPlaceholder
|
import io.element.android.features.location.api.internal.StaticMapPlaceholder
|
||||||
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
|
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
|
||||||
import io.element.android.features.location.api.internal.centerBottomEdge
|
import io.element.android.features.location.api.internal.centerBottomEdge
|
||||||
import io.element.android.libraries.designsystem.components.LocationPinMarker
|
import io.element.android.libraries.designsystem.components.LocationPin
|
||||||
import io.element.android.libraries.designsystem.components.PinVariant
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a static map image downloaded via a third party service's static maps API.
|
* Shows a static map image downloaded via a third party service's static maps API.
|
||||||
|
|
@ -98,7 +95,7 @@ fun StaticMapView(
|
||||||
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
|
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
LocationPinMarker(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
|
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
|
||||||
} else {
|
} else {
|
||||||
StaticMapPlaceholder(
|
StaticMapPlaceholder(
|
||||||
showProgress = collectedState.value.isLoading(),
|
showProgress = collectedState.value.isLoading(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.libraries.designsystem.components.LocationPin
|
||||||
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.maplibre.compose.expressions.dsl.and
|
||||||
|
import org.maplibre.compose.expressions.dsl.asNumber
|
||||||
|
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.image
|
||||||
|
import org.maplibre.compose.expressions.dsl.not
|
||||||
|
import org.maplibre.compose.expressions.dsl.step
|
||||||
|
import org.maplibre.compose.expressions.value.SymbolAnchor
|
||||||
|
import org.maplibre.compose.expressions.value.SymbolPlacement
|
||||||
|
import org.maplibre.compose.layers.CircleLayer
|
||||||
|
import org.maplibre.compose.layers.SymbolLayer
|
||||||
|
import org.maplibre.compose.sources.GeoJsonData
|
||||||
|
import org.maplibre.compose.sources.GeoJsonOptions
|
||||||
|
import org.maplibre.compose.sources.GeoJsonSource
|
||||||
|
import org.maplibre.compose.sources.rememberGeoJsonSource
|
||||||
|
import org.maplibre.compose.util.ClickResult
|
||||||
|
import org.maplibre.spatialk.geojson.Feature
|
||||||
|
import org.maplibre.spatialk.geojson.FeatureCollection
|
||||||
|
import org.maplibre.spatialk.geojson.Point
|
||||||
|
import org.maplibre.spatialk.geojson.Position
|
||||||
|
import org.maplibre.spatialk.geojson.toJson
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class representing a marker on the map.
|
||||||
|
*
|
||||||
|
* @param id Unique identifier for the marker
|
||||||
|
* @param location The geographic location of the marker
|
||||||
|
* @param variant The visual variant of the pin (user location, pinned, stale)
|
||||||
|
*/
|
||||||
|
data class LocationMarkerData(
|
||||||
|
val id: String,
|
||||||
|
val location: Location,
|
||||||
|
val variant: PinVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable that renders location markers on a MapLibre map with clustering support.
|
||||||
|
*
|
||||||
|
* Uses GeoJSON source with clustering enabled to group nearby markers.
|
||||||
|
* Individual markers are rendered using [LocationPin] composable converted to bitmaps.
|
||||||
|
* Clusters are rendered as circles with point counts.
|
||||||
|
*
|
||||||
|
* Must be used within a MaplibreMap content block.
|
||||||
|
*
|
||||||
|
* @param markers List of markers to display on the map
|
||||||
|
* @param clusterRadius Radius of each cluster when clustering points (default 50)
|
||||||
|
* @param clusterMaxZoom Maximum zoom level at which to cluster points (default 14)
|
||||||
|
* @param onMarkerClick Callback when a marker is clicked
|
||||||
|
* @param onClusterClick Callback when a cluster is clicked, provides cluster center position
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LocationPinMarkers(
|
||||||
|
markers: List<LocationMarkerData>,
|
||||||
|
onMarkerClick: ((LocationMarkerData) -> Unit)? = null,
|
||||||
|
onClusterClick: ((Position) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
if (markers.isEmpty()) return
|
||||||
|
val clusterColor = ElementTheme.colors.bgAccentRest
|
||||||
|
val clusterStrokeColor = ElementTheme.colors.iconOnSolidPrimary
|
||||||
|
val clusterTextColor = ElementTheme.colors.textOnSolidPrimary
|
||||||
|
val clusterTextStyle = ElementTheme.typography.fontBodyMdMedium
|
||||||
|
|
||||||
|
// Convert markers to GeoJSON
|
||||||
|
val geoJsonString = remember(markers) {
|
||||||
|
val features = markers.map { marker ->
|
||||||
|
Feature(
|
||||||
|
id = JsonPrimitive(marker.id),
|
||||||
|
geometry = Point(Position(marker.location.lon, marker.location.lat)),
|
||||||
|
properties = mapOf(
|
||||||
|
"id" to JsonPrimitive(marker.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FeatureCollection(features).toJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create GeoJSON source with clustering
|
||||||
|
val markersSource = rememberGeoJsonSource(
|
||||||
|
data = GeoJsonData.JsonString(geoJsonString),
|
||||||
|
options = GeoJsonOptions(
|
||||||
|
cluster = true,
|
||||||
|
clusterMinPoints = 3,
|
||||||
|
clusterRadius = 30
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cluster circle layer
|
||||||
|
CircleLayer(
|
||||||
|
id = "cluster-circles",
|
||||||
|
source = markersSource,
|
||||||
|
filter = feature.has("point_count"),
|
||||||
|
color = const(clusterColor),
|
||||||
|
radius = const(24.dp),
|
||||||
|
strokeWidth = const(1.dp),
|
||||||
|
strokeColor = const(clusterStrokeColor),
|
||||||
|
onClick = { features ->
|
||||||
|
features.firstOrNull()?.let { feat ->
|
||||||
|
val point = feat.geometry as? Point
|
||||||
|
if (point != null && onClusterClick != null) {
|
||||||
|
onClusterClick(point.coordinates)
|
||||||
|
ClickResult.Consume
|
||||||
|
} else {
|
||||||
|
ClickResult.Pass
|
||||||
|
}
|
||||||
|
} ?: ClickResult.Pass
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cluster count text layer
|
||||||
|
SymbolLayer(
|
||||||
|
id = "cluster-count",
|
||||||
|
source = markersSource,
|
||||||
|
filter = feature.has("point_count"),
|
||||||
|
textField = feature["point_count_abbreviated"].asString(),
|
||||||
|
textColor = const(clusterTextColor),
|
||||||
|
textSize = const(clusterTextStyle.fontSize),
|
||||||
|
textFont = const(listOfNotNull(clusterTextStyle.fontFamily?.toString())),
|
||||||
|
textLetterSpacing = const(clusterTextStyle.letterSpacing),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Individual marker layers - one per marker for unique avatars
|
||||||
|
markers.forEach { marker ->
|
||||||
|
LocationPinMarkerLayer(
|
||||||
|
marker = marker,
|
||||||
|
source = markersSource,
|
||||||
|
onMarkerClick = onMarkerClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LocationPinMarkerLayer(
|
||||||
|
marker: LocationMarkerData,
|
||||||
|
source: GeoJsonSource,
|
||||||
|
onMarkerClick: ((LocationMarkerData) -> Unit)?,
|
||||||
|
) {
|
||||||
|
val imageBitmap = rememberLocationPinImage(marker.variant)
|
||||||
|
SymbolLayer(
|
||||||
|
id = "pin-marker-${marker.id}",
|
||||||
|
source = source,
|
||||||
|
filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)),
|
||||||
|
iconImage = image(imageBitmap),
|
||||||
|
iconAnchor = const(SymbolAnchor.Bottom),
|
||||||
|
iconAllowOverlap = const(true),
|
||||||
|
onClick = { features ->
|
||||||
|
if (features.isNotEmpty() && onMarkerClick != null) {
|
||||||
|
onMarkerClick(marker)
|
||||||
|
ClickResult.Consume
|
||||||
|
} else {
|
||||||
|
ClickResult.Pass
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a LocationPin composable to an ImageBitmap for use in SymbolLayer.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberLocationPinImage(variant: PinVariant): ImageBitmap {
|
||||||
|
val bitmap = rememberMarkerBitmap(variant) {
|
||||||
|
LocationPin(variant = variant)
|
||||||
|
}
|
||||||
|
return bitmap.asImageBitmap()
|
||||||
|
}
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2026 Element Creations Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.features.location.impl.common.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import org.maplibre.compose.camera.CameraState
|
|
||||||
import org.maplibre.spatialk.geojson.Position
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MapProjected(
|
|
||||||
target: Position,
|
|
||||||
cameraState: CameraState,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.graphicsLayer {
|
|
||||||
cameraState.position
|
|
||||||
val offset = cameraState.projection?.screenLocationFromPosition(target)
|
|
||||||
if (offset != null) {
|
|
||||||
translationX = offset.x.toPx() - size.width / 2
|
|
||||||
translationY = offset.y.toPx() - size.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionContext
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCompositionContext
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a composable [content] to an Android [Bitmap].
|
||||||
|
* Useful for MapLibre SymbolLayer rendering.
|
||||||
|
*
|
||||||
|
* Uses a temporary ComposeView to render off-screen without
|
||||||
|
* adding to the visible composition tree.
|
||||||
|
*
|
||||||
|
* Note: This function provides a software-only ImageLoader to avoid
|
||||||
|
* "Software rendering doesn't support hardware bitmaps" errors when
|
||||||
|
* rendering Coil images to a Canvas.
|
||||||
|
*
|
||||||
|
* @param keys to trigger recomposition.
|
||||||
|
* @return The rendered Android [Bitmap].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberMarkerBitmap(
|
||||||
|
vararg keys: Any,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
): Bitmap {
|
||||||
|
val parent = LocalView.current as ViewGroup
|
||||||
|
val compositionContext = rememberCompositionContext()
|
||||||
|
return remember(parent, compositionContext, *keys) {
|
||||||
|
renderComposableToBitmap(parent, compositionContext, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderComposableToBitmap(
|
||||||
|
parent: ViewGroup,
|
||||||
|
compositionContext: CompositionContext,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
): Bitmap {
|
||||||
|
val composeView = ComposeView(parent.context).apply {
|
||||||
|
setParentCompositionContext(compositionContext)
|
||||||
|
setContent(content)
|
||||||
|
}
|
||||||
|
// Temporarily add to parent for measurement
|
||||||
|
parent.addView(
|
||||||
|
composeView,
|
||||||
|
ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Measure
|
||||||
|
composeView.measure(
|
||||||
|
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
|
||||||
|
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||||
|
)
|
||||||
|
|
||||||
|
val width = composeView.measuredWidth
|
||||||
|
val height = composeView.measuredHeight
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
composeView.layout(0, 0, width, height)
|
||||||
|
|
||||||
|
// Draw to bitmap
|
||||||
|
val bitmap = createBitmap(width, height)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
composeView.draw(canvas)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
parent.removeView(composeView)
|
||||||
|
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ import io.element.android.features.location.impl.common.PermissionRationaleDialo
|
||||||
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
||||||
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
|
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
|
||||||
import io.element.android.features.location.impl.common.ui.UserLocationPuck
|
import io.element.android.features.location.impl.common.ui.UserLocationPuck
|
||||||
import io.element.android.libraries.designsystem.components.LocationPinMarker
|
import io.element.android.libraries.designsystem.components.LocationPin
|
||||||
import io.element.android.libraries.designsystem.components.PinVariant
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
|
@ -160,7 +160,7 @@ fun ShareLocationView(
|
||||||
} else {
|
} else {
|
||||||
PinVariant.PinnedLocation
|
PinVariant.PinnedLocation
|
||||||
}
|
}
|
||||||
LocationPinMarker(
|
LocationPin(
|
||||||
variant = variant,
|
variant = variant,
|
||||||
modifier = Modifier.centerBottomEdge(this),
|
modifier = Modifier.centerBottomEdge(this),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@
|
||||||
|
|
||||||
package io.element.android.features.location.impl.show
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SheetValue
|
import androidx.compose.material3.SheetValue
|
||||||
|
|
@ -17,25 +15,23 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
import androidx.compose.material3.rememberStandardBottomSheetState
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.compound.tokens.generated.TypographyTokens
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.features.location.api.ShowLocationMode
|
import io.element.android.features.location.api.ShowLocationMode
|
||||||
import io.element.android.features.location.impl.common.MapDefaults
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
import io.element.android.features.location.impl.common.PermissionDeniedDialog
|
import io.element.android.features.location.impl.common.PermissionDeniedDialog
|
||||||
import io.element.android.features.location.impl.common.PermissionRationaleDialog
|
import io.element.android.features.location.impl.common.PermissionRationaleDialog
|
||||||
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationMarkerData
|
||||||
|
import io.element.android.features.location.impl.common.ui.LocationPinMarkers
|
||||||
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
|
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
|
||||||
import io.element.android.features.location.impl.common.ui.MapProjected
|
|
||||||
import io.element.android.features.location.impl.common.ui.UserLocationPuck
|
import io.element.android.features.location.impl.common.ui.UserLocationPuck
|
||||||
import io.element.android.libraries.designsystem.components.LocationPinMarker
|
|
||||||
import io.element.android.libraries.designsystem.components.PinVariant
|
import io.element.android.libraries.designsystem.components.PinVariant
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
|
@ -44,7 +40,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -56,6 +51,10 @@ import org.maplibre.compose.location.rememberDefaultLocationProvider
|
||||||
import org.maplibre.compose.location.rememberNullLocationProvider
|
import org.maplibre.compose.location.rememberNullLocationProvider
|
||||||
import org.maplibre.compose.location.rememberUserLocationState
|
import org.maplibre.compose.location.rememberUserLocationState
|
||||||
import org.maplibre.spatialk.geojson.Position
|
import org.maplibre.spatialk.geojson.Position
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.random.Random
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -138,8 +137,6 @@ fun ShowLocationView(
|
||||||
locationState = userLocationState,
|
locationState = userLocationState,
|
||||||
trackUserLocation = state.isTrackMyLocation
|
trackUserLocation = state.isTrackMyLocation
|
||||||
)
|
)
|
||||||
},
|
|
||||||
overlayContent = {
|
|
||||||
when (val mode = state.mode) {
|
when (val mode = state.mode) {
|
||||||
is ShowLocationMode.Static -> {
|
is ShowLocationMode.Static -> {
|
||||||
val pinVariant = if (mode.assetType == AssetType.PIN) {
|
val pinVariant = if (mode.assetType == AssetType.PIN) {
|
||||||
|
|
@ -147,23 +144,64 @@ fun ShowLocationView(
|
||||||
} else {
|
} else {
|
||||||
PinVariant.UserLocation(
|
PinVariant.UserLocation(
|
||||||
avatarData = AvatarData(mode.senderId.value, mode.senderName, mode.senderAvatarUrl, AvatarSize.UserListItem),
|
avatarData = AvatarData(mode.senderId.value, mode.senderName, mode.senderAvatarUrl, AvatarSize.UserListItem),
|
||||||
isLive = false
|
isLive = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val position = Position(
|
// Generate test markers around the original location
|
||||||
latitude = mode.location.lat,
|
val testMarkers = remember {
|
||||||
longitude = mode.location.lon
|
buildList {
|
||||||
)
|
// Add the original marker
|
||||||
MapProjected(target = position, cameraState = cameraState) {
|
add(
|
||||||
LocationPinMarker(variant = pinVariant)
|
LocationMarkerData(
|
||||||
|
id = "original",
|
||||||
|
location = mode.location,
|
||||||
|
variant = pinVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Generate 10 random points within 50 meters
|
||||||
|
val radiusInMeters = 50.0
|
||||||
|
val metersPerDegreeLat = 111_320.0
|
||||||
|
val metersPerDegreeLon = 111_320.0 * cos(Math.toRadians(mode.location.lat))
|
||||||
|
val variants = listOf(
|
||||||
|
PinVariant.StaleLocation,
|
||||||
|
PinVariant.UserLocation(AvatarData("@alice", "Alice", null, AvatarSize.TimelineSender), isLive = true),
|
||||||
|
PinVariant.UserLocation(AvatarData("@bob", "Bob", null, AvatarSize.TimelineSender), isLive = true),
|
||||||
|
PinVariant.UserLocation(AvatarData("@cassy", "Cassy", null, AvatarSize.TimelineSender), isLive = true),
|
||||||
|
PinVariant.UserLocation(AvatarData("@daisy", "Daisy", null, AvatarSize.TimelineSender), isLive = true),
|
||||||
|
PinVariant.UserLocation(AvatarData("@en", "G", null, AvatarSize.TimelineSender), isLive = true),
|
||||||
|
PinVariant.UserLocation(AvatarData("@f", "H", null, AvatarSize.TimelineSender), isLive = true),
|
||||||
|
PinVariant.UserLocation(AvatarData("@g", "I", null, AvatarSize.TimelineSender), isLive = true),
|
||||||
|
PinVariant.UserLocation(AvatarData("@h", "J", null, AvatarSize.TimelineSender), isLive = true),
|
||||||
|
PinVariant.UserLocation(AvatarData("@i", "K", null, AvatarSize.TimelineSender), isLive = true),
|
||||||
|
)
|
||||||
|
repeat(10) { index ->
|
||||||
|
// Random point in a circle using sqrt for uniform distribution
|
||||||
|
val angle = Random.nextDouble() * 2 * Math.PI
|
||||||
|
val distance = sqrt(Random.nextDouble()) * radiusInMeters
|
||||||
|
val latOffset = (distance * cos(angle)) / metersPerDegreeLat
|
||||||
|
val lonOffset = (distance * sin(angle)) / metersPerDegreeLon
|
||||||
|
add(
|
||||||
|
LocationMarkerData(
|
||||||
|
id = "test_$index",
|
||||||
|
location = Location(
|
||||||
|
lat = mode.location.lat + latOffset,
|
||||||
|
lon = mode.location.lon + lonOffset
|
||||||
|
),
|
||||||
|
variant = variants[index % (variants.size-1)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
LocationPinMarkers(testMarkers)
|
||||||
}
|
}
|
||||||
ShowLocationMode.Live -> {
|
ShowLocationMode.Live -> {
|
||||||
// TODO: Show pins for all active live location sharers
|
// TODO: Show pins for all active live location sharers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
overlayContent = {
|
||||||
LocationFloatingActionButton(
|
LocationFloatingActionButton(
|
||||||
isMapCenteredOnUser = state.isTrackMyLocation,
|
isMapCenteredOnUser = state.isTrackMyLocation,
|
||||||
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
|
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,12 @@ private val DOT_RADIUS = 6.dp
|
||||||
private val CONTENT_OFFSET = 5.dp
|
private val CONTENT_OFFSET = 5.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A location pin marker composable that supports multiple variants.
|
* A location pin composable that supports multiple variants.
|
||||||
*
|
*
|
||||||
* Based on Figma design: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4665-2890&m=dev
|
* Based on Figma design: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4665-2890&m=dev
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LocationPinMarker(
|
fun LocationPin(
|
||||||
variant: PinVariant,
|
variant: PinVariant,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -195,7 +195,7 @@ private fun DrawScope.drawPinShape(
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LocationPinMarkerPreview() = ElementPreview {
|
internal fun LocationPinPreview() = ElementPreview {
|
||||||
val sampleAvatarData = AvatarData(
|
val sampleAvatarData = AvatarData(
|
||||||
id = "@alice:matrix.org",
|
id = "@alice:matrix.org",
|
||||||
name = "Alice",
|
name = "Alice",
|
||||||
|
|
@ -208,18 +208,18 @@ internal fun LocationPinMarkerPreview() = ElementPreview {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
LocationPinMarker(
|
LocationPin(
|
||||||
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = false),
|
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = false),
|
||||||
)
|
)
|
||||||
LocationPinMarker(
|
LocationPin(
|
||||||
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = true),
|
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
LocationPinMarker(
|
LocationPin(
|
||||||
variant = PinVariant.PinnedLocation,
|
variant = PinVariant.PinnedLocation,
|
||||||
)
|
)
|
||||||
LocationPinMarker(
|
LocationPin(
|
||||||
variant = PinVariant.StaleLocation,
|
variant = PinVariant.StaleLocation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue