Set up SDK & reusable map view component (#476)
Adds `libraries/map` which contains some initial building blocks that will be used by the location sharing feature. Ref: https://github.com/vector-im/element-meta/issues/1684
This commit is contained in:
parent
4fe7bb6809
commit
882f75864c
25 changed files with 1082 additions and 0 deletions
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api
|
||||
|
||||
/**
|
||||
* Represents a location sample emitted by the device's location subsystem.
|
||||
*/
|
||||
data class Location(
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
val accuracy: Float,
|
||||
)
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.mapbox.mapboxsdk.Mapbox
|
||||
import com.mapbox.mapboxsdk.camera.CameraPosition
|
||||
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory
|
||||
import com.mapbox.mapboxsdk.geometry.LatLng
|
||||
import com.mapbox.mapboxsdk.maps.MapView
|
||||
import com.mapbox.mapboxsdk.maps.MapboxMap
|
||||
import com.mapbox.mapboxsdk.maps.Style
|
||||
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
|
||||
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
|
||||
import io.element.android.features.location.api.internal.buildTileServerUrl
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import timber.log.Timber
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Composable wrapper around MapLibre's [MapView].
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
@Composable
|
||||
fun MapView(
|
||||
modifier: Modifier = Modifier,
|
||||
mapState: MapState = rememberMapState(),
|
||||
darkMode: Boolean = !ElementTheme.colors.isLight,
|
||||
onLocationClick: () -> Unit,
|
||||
) {
|
||||
// When in preview, early return a Box with the received modifier preserving layout
|
||||
if (LocalInspectionMode.current) {
|
||||
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
|
||||
Box(modifier = modifier)
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val mapView = remember {
|
||||
Mapbox.getInstance(context)
|
||||
MapView(context)
|
||||
}
|
||||
var mapRefs by remember { mutableStateOf<MapRefs?>(null) }
|
||||
|
||||
// Build map
|
||||
LaunchedEffect(darkMode) {
|
||||
mapView.awaitMap().let { map ->
|
||||
map.uiSettings.apply {
|
||||
isCompassEnabled = false
|
||||
}
|
||||
map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style ->
|
||||
mapRefs = MapRefs(
|
||||
map = map,
|
||||
symbolManager = SymbolManager(mapView, map, style).apply {
|
||||
iconAllowOverlap = true
|
||||
},
|
||||
style = style
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update state position when moving map
|
||||
DisposableEffect(mapRefs) {
|
||||
var listener: MapboxMap.OnCameraIdleListener? = null
|
||||
|
||||
mapRefs?.let { mapRefs ->
|
||||
listener = MapboxMap.OnCameraIdleListener {
|
||||
mapRefs.map.cameraPosition.target?.let { target ->
|
||||
val position = MapState.CameraPosition(
|
||||
lat = target.latitude,
|
||||
lon = target.longitude,
|
||||
zoom = mapRefs.map.cameraPosition.zoom
|
||||
)
|
||||
mapState.position = position
|
||||
Timber.d("Camera moved to: $position")
|
||||
}
|
||||
}.apply {
|
||||
mapRefs.map.addOnCameraIdleListener(this)
|
||||
Timber.d("Added OnCameraIdleListener $this")
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
mapRefs?.let { mapRefs ->
|
||||
listener?.let {
|
||||
mapRefs.map.removeOnCameraIdleListener(it).apply {
|
||||
Timber.d("Removed OnCameraIdleListener $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move map to given position when state has changed
|
||||
LaunchedEffect(mapRefs, mapState.position) {
|
||||
mapRefs?.map?.moveCamera(
|
||||
CameraUpdateFactory.newCameraPosition(
|
||||
CameraPosition.Builder()
|
||||
.target(LatLng(mapState.position.lat, mapState.position.lon))
|
||||
.zoom(mapState.position.zoom).build()
|
||||
)
|
||||
)
|
||||
Timber.d("Camera position updated to: ${mapState.position}")
|
||||
}
|
||||
|
||||
// Draw pin
|
||||
LaunchedEffect(mapRefs, mapState.location) {
|
||||
mapRefs?.let { mapRefs ->
|
||||
mapState.location?.let { location ->
|
||||
context.getDrawable(R.drawable.pin)?.let { mapRefs.style.addImage("pin", it) }
|
||||
mapRefs.symbolManager.create(
|
||||
SymbolOptions()
|
||||
.withLatLng(LatLng(location.lat, location.lon))
|
||||
.withIconImage("pin")
|
||||
.withIconSize(1.3f)
|
||||
)
|
||||
Timber.d("Shown pin at location: $location")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Draw markers
|
||||
LaunchedEffect(mapRefs, mapState.markers) {
|
||||
mapRefs?.let { mapRefs ->
|
||||
mapState.markers.forEachIndexed { index, marker ->
|
||||
context.getDrawable(marker.drawable)?.let { mapRefs.style.addImage("marker_$index", it) }
|
||||
mapRefs.symbolManager.create(
|
||||
SymbolOptions()
|
||||
.withLatLng(LatLng(marker.lat, marker.lon))
|
||||
.withIconImage("marker_$index")
|
||||
.withIconSize(1.0f)
|
||||
)
|
||||
Timber.d("Shown marker at location: $marker")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ModifierReused")
|
||||
Box(modifier = modifier) {
|
||||
AndroidView(factory = { mapView })
|
||||
FloatingActionButton(
|
||||
onClick = onLocationClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.LocationOn,
|
||||
contentDescription = null, // TODO
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberMapState(
|
||||
position: MapState.CameraPosition = MapState.CameraPosition(lat = 0.0, lon = 0.0, zoom = 0.0),
|
||||
location: Location? = null,
|
||||
markers: ImmutableList<MapState.Marker> = emptyList<MapState.Marker>().toImmutableList(),
|
||||
): MapState = remember {
|
||||
MapState(
|
||||
position = position,
|
||||
location = location,
|
||||
markers = markers,
|
||||
)
|
||||
} // TODO(Use remember saveable with Parcelable custom saver)
|
||||
|
||||
@Stable
|
||||
class MapState(
|
||||
position: CameraPosition, // The position of the camera, it's what will be shared
|
||||
location: Location? = null, // The location retrieved by the location subsystem, if any.
|
||||
markers: ImmutableList<Marker> = emptyList<Marker>().toImmutableList(), // The pin's location, if any.
|
||||
) {
|
||||
var position: CameraPosition by mutableStateOf(position)
|
||||
var location: Location? by mutableStateOf(location)
|
||||
var markers: ImmutableList<Marker> by mutableStateOf(markers)
|
||||
|
||||
override fun toString(): String {
|
||||
return "MapState(position=$position, location=$location, markers=$markers)"
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class CameraPosition(
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
val zoom: Double,
|
||||
)
|
||||
|
||||
@Stable
|
||||
data class Marker(
|
||||
@DrawableRes val drawable: Int,
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
)
|
||||
}
|
||||
|
||||
private class MapRefs(
|
||||
val map: MapboxMap,
|
||||
val symbolManager: SymbolManager,
|
||||
val style: Style
|
||||
)
|
||||
|
||||
/**
|
||||
* A suspending function that provides an instance of [MapboxMap] from this [MapView]. This is
|
||||
* an alternative to [MapView.getMapAsync] by using coroutines to obtain the [MapboxMap].
|
||||
*
|
||||
* Inspired from [com.google.maps.android.ktx.awaitMap]
|
||||
*
|
||||
* @return the [MapboxMap] instance
|
||||
*/
|
||||
private suspend inline fun MapView.awaitMap(): MapboxMap =
|
||||
suspendCoroutine { continuation ->
|
||||
getMapAsync {
|
||||
continuation.resume(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun MapViewLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun MapViewDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
MapView(
|
||||
modifier = Modifier.size(400.dp),
|
||||
mapState = rememberMapState(
|
||||
position = MapState.CameraPosition(
|
||||
lat = 0.0,
|
||||
lon = 0.0,
|
||||
zoom = 0.0,
|
||||
),
|
||||
location = Location(
|
||||
lat = 0.0,
|
||||
lon = 0.0,
|
||||
accuracy = 0.0f,
|
||||
),
|
||||
markers = listOf(
|
||||
MapState.Marker(
|
||||
drawable = R.drawable.pin,
|
||||
lat = 0.0,
|
||||
lon = 0.0,
|
||||
)
|
||||
).toImmutableList()
|
||||
),
|
||||
onLocationClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.features.location.api.internal.StaticMapPlaceholder
|
||||
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Shows a static map image downloaded via a third party service's static maps API.
|
||||
*/
|
||||
@Composable
|
||||
fun StaticMapView(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
zoom: Double,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
darkMode: Boolean = !ElementTheme.colors.isLight,
|
||||
) {
|
||||
// Using BoxWithConstraints to:
|
||||
// 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints.
|
||||
// 2) Request the static map image of the exact required size in Px to fill the AsyncImage.
|
||||
BoxWithConstraints(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
var retryHash by remember { mutableStateOf(0) }
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = if (constraints.isZero) {
|
||||
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
|
||||
null
|
||||
} else {
|
||||
ImageRequest.Builder(LocalContext.current)
|
||||
.data(
|
||||
buildStaticMapsApiUrl(
|
||||
lat = lat,
|
||||
lon = lon,
|
||||
desiredZoom = zoom,
|
||||
desiredWidth = constraints.maxWidth,
|
||||
desiredHeight = constraints.maxHeight,
|
||||
darkMode = darkMode,
|
||||
)
|
||||
)
|
||||
.size(width = constraints.maxWidth, height = constraints.maxHeight)
|
||||
.setParameter("retry_hash", retryHash, memoryCacheKey = null)
|
||||
.build()
|
||||
}.apply {
|
||||
Timber.d("Static map image request: ${this?.data}")
|
||||
}
|
||||
)
|
||||
|
||||
if (painter.state is AsyncImagePainter.State.Success) {
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(width = maxWidth, height = maxHeight),
|
||||
// The returned image can be smaller than the requested size due to the static maps API having
|
||||
// a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details.
|
||||
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Icon(
|
||||
resourceId = R.drawable.pin,
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
StaticMapPlaceholder(
|
||||
showProgress = painter.state is AsyncImagePainter.State.Loading,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(width = maxWidth, height = maxHeight),
|
||||
darkMode = darkMode,
|
||||
onLoadMapClick = { retryHash++ }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StaticMapViewLightPreview() =
|
||||
ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StaticMapViewDarkPreview() =
|
||||
ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
StaticMapView(
|
||||
lat = 0.0,
|
||||
lon = 0.0,
|
||||
zoom = 0.0,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(400.dp),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val API_KEY = "fU3vlMsMn4Jb6dnEIFsx"
|
||||
private const val BASE_URL = "https://api.maptiler.com"
|
||||
private const val LIGHT_MAP_ID = "9bc819c8-e627-474a-a348-ec144fe3d810"
|
||||
private const val DARK_MAP_ID = "dea61faf-292b-4774-9660-58fcef89a7f3"
|
||||
private const val STATIC_MAP_FORMAT = "webp"
|
||||
private const val STATIC_MAP_SCALE = "" // Either "" (empty string) for normal image or "@2x" for retina images.
|
||||
private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048
|
||||
private const val STATIC_MAP_MAX_ZOOM = 22.0
|
||||
|
||||
internal fun buildTileServerUrl(
|
||||
darkMode: Boolean
|
||||
): String = if (!darkMode) {
|
||||
"$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY"
|
||||
} else {
|
||||
"$BASE_URL/maps/$DARK_MAP_ID/style.json?key=$API_KEY"
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a valid URL for maptiler.com static map api based on the given params.
|
||||
*
|
||||
* Coerces width and height to the API maximum of 2048 keeping the requested aspect ratio.
|
||||
* Coerces zoom to the API maximum of 22.
|
||||
*
|
||||
* NB: This will throw if either width or height are <= 0. You need to handle this case upstream
|
||||
* (hint: views can't have negative width or height but can have 0 width or height sometimes).
|
||||
*/
|
||||
internal fun buildStaticMapsApiUrl(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
desiredZoom: Double,
|
||||
desiredWidth: Int,
|
||||
desiredHeight: Int,
|
||||
darkMode: Boolean
|
||||
): String {
|
||||
require(desiredWidth > 0 && desiredHeight > 0) {
|
||||
"Width ($desiredHeight) and height ($desiredHeight) must be > 0"
|
||||
}
|
||||
require(desiredZoom >= 0) { "Zoom ($desiredZoom) must be >= 0" }
|
||||
val zoom = desiredZoom.coerceAtMost(STATIC_MAP_MAX_ZOOM) // API will error if outside 0-22 range.
|
||||
val width: Int
|
||||
val height: Int
|
||||
if (desiredWidth <= STATIC_MAP_MAX_WIDTH_HEIGHT && desiredHeight <= STATIC_MAP_MAX_WIDTH_HEIGHT) {
|
||||
width = desiredWidth
|
||||
height = desiredHeight
|
||||
} else {
|
||||
val aspectRatio = desiredWidth.toDouble() / desiredHeight.toDouble()
|
||||
if (desiredWidth >= desiredHeight) {
|
||||
width = desiredWidth.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT)
|
||||
height = (width / aspectRatio).roundToInt()
|
||||
} else {
|
||||
height = desiredHeight.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT)
|
||||
width = (height * aspectRatio).roundToInt()
|
||||
}
|
||||
}
|
||||
return if (!darkMode) {
|
||||
"$BASE_URL/maps/$LIGHT_MAP_ID/static/${lon},${lat},${zoom}/${width}x${height}$STATIC_MAP_SCALE.$STATIC_MAP_FORMAT?key=$API_KEY"
|
||||
} else {
|
||||
"$BASE_URL/maps/$DARK_MAP_ID/static/${lon},${lat},${zoom}/${width}x${height}$STATIC_MAP_SCALE.$STATIC_MAP_FORMAT?key=$API_KEY"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.features.location.api.R
|
||||
import io.element.android.libraries.ui.strings.R as StringsR
|
||||
|
||||
@Composable
|
||||
internal fun StaticMapPlaceholder(
|
||||
showProgress: Boolean,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
darkMode: Boolean = !ElementTheme.colors.isLight,
|
||||
onLoadMapClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if (darkMode) R.drawable.blurred_map_dark
|
||||
else R.drawable.blurred_map_light
|
||||
),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.FillBounds,
|
||||
)
|
||||
if (showProgress) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Box(
|
||||
modifier = modifier.clickable(onClick = onLoadMapClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = null
|
||||
)
|
||||
Text(text = stringResource(id = StringsR.string.action_static_map_load))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StaticMapPlaceholderLightPreview(
|
||||
@PreviewParameter(BooleanParameterProvider::class) values: Boolean
|
||||
) = ElementPreviewLight { ContentToPreview(values) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StaticMapPlaceholderDarkPreview(
|
||||
@PreviewParameter(BooleanParameterProvider::class) values: Boolean
|
||||
) = ElementPreviewDark { ContentToPreview(values) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(showProgress: Boolean) {
|
||||
StaticMapPlaceholder(
|
||||
showProgress = showProgress,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(400.dp),
|
||||
onLoadMapClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
internal class BooleanParameterProvider : PreviewParameterProvider<Boolean> {
|
||||
override val values: Sequence<Boolean>
|
||||
get() = sequenceOf(true, false)
|
||||
}
|
||||
BIN
features/location/api/src/main/res/drawable/blurred_map_dark.png
Normal file
BIN
features/location/api/src/main/res/drawable/blurred_map_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
23
features/location/api/src/main/res/drawable/pin.xml
Normal file
23
features/location/api/src/main/res/drawable/pin.xml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="50dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="50"
|
||||
android:viewportHeight="108">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h50v108h-50z"/>
|
||||
<path
|
||||
android:pathData="M25,54L18.94,48L31.06,48L25,54Z"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<path
|
||||
android:pathData="M25,25m-25,0a25,25 0,1 1,50 0a25,25 0,1 1,-50 0"
|
||||
android:fillColor="#1B1D22"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M13,13h24v24h-24z"/>
|
||||
<path
|
||||
android:pathData="M25,13C20.36,13 16.6,16.86 16.6,21.63C16.6,26.77 21.9,33.86 24.09,36.56C24.57,37.15 25.44,37.15 25.92,36.56C28.1,33.86 33.4,26.77 33.4,21.63C33.4,16.86 29.64,13 25,13ZM25,24.71C23.34,24.71 22,23.33 22,21.63C22,19.93 23.34,18.55 25,18.55C26.66,18.55 28,19.93 28,21.63C28,23.33 26.66,24.71 25,24.71Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
|
||||
import org.junit.Test
|
||||
|
||||
class BuildStaticMapsApiUrlTest {
|
||||
@Test
|
||||
fun `buildStaticMapsApiUrl builds light mode url`() {
|
||||
assertThat(
|
||||
buildStaticMapsApiUrl(
|
||||
lat = 1.234,
|
||||
lon = 5.678,
|
||||
desiredZoom = 1.2,
|
||||
desiredWidth = 100,
|
||||
desiredHeight = 200,
|
||||
darkMode = false
|
||||
)
|
||||
).isEqualTo(
|
||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildStaticMapsApiUrl builds dark mode url`() {
|
||||
assertThat(
|
||||
buildStaticMapsApiUrl(
|
||||
lat = 1.234,
|
||||
lon = 5.678,
|
||||
desiredZoom = 1.2,
|
||||
desiredWidth = 100,
|
||||
desiredHeight = 200,
|
||||
darkMode = true
|
||||
)
|
||||
).isEqualTo(
|
||||
"https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp?key=fU3vlMsMn4Jb6dnEIFsx"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildStaticMapsApiUrl coerces zoom at 22 and width and height at max 2048 keeping aspect ratio`() {
|
||||
assertThat(
|
||||
buildStaticMapsApiUrl(
|
||||
lat = 1.234,
|
||||
lon = 5.678,
|
||||
desiredZoom = 100.0,
|
||||
desiredWidth = 8192,
|
||||
desiredHeight = 4096,
|
||||
darkMode = false
|
||||
)
|
||||
).isEqualTo(
|
||||
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp?key=fU3vlMsMn4Jb6dnEIFsx"
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue