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
|
|
@ -247,6 +247,7 @@ koverMerged {
|
|||
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
|
||||
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
|
||||
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
|
||||
excludes += "io.element.android.features.location.api.MapState"
|
||||
}
|
||||
bound {
|
||||
minValue = 90
|
||||
|
|
|
|||
53
features/location/api/build.gradle.kts
Normal file
53
features/location/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.location.api"
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.dagger)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(libs.maplibre)
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.maplibre.annotation)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.accompanist.permission)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
52
features/location/fake/build.gradle.kts
Normal file
52
features/location/fake/build.gradle.kts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.location.fake"
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.dagger)
|
||||
api(projects.features.location.api)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(libs.maplibre)
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.maplibre.annotation)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.accompanist.permission)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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.fake
|
||||
|
||||
import io.element.android.features.location.api.Location
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
fun fakeLocationUpdatesFlow(): Flow<Location> = flow {
|
||||
while (true) {
|
||||
delay(1_000)
|
||||
emit(aLocation())
|
||||
}
|
||||
}
|
||||
|
||||
private fun aLocation() = Location(
|
||||
lat = 51.49404,
|
||||
lon = -0.25484,
|
||||
accuracy = 5f
|
||||
)
|
||||
54
features/location/impl/build.gradle.kts
Normal file
54
features/location/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.location.impl"
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.dagger)
|
||||
api(projects.features.location.api)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(libs.maplibre)
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.maplibre.annotation)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.accompanist.permission)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
}
|
||||
21
features/location/impl/src/main/AndroidManifest.xml
Normal file
21
features/location/impl/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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.impl
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.location.LocationManager
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import androidx.core.location.LocationRequestCompat
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.features.location.api.Location
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
/**
|
||||
* Returns a cold [Flow] that, once collected, emits [Location] updates every second.
|
||||
*/
|
||||
@RequiresPermission(
|
||||
anyOf = [
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
]
|
||||
)
|
||||
fun locationUpdatesFlow(
|
||||
context: Context,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
): Flow<Location> = callbackFlow {
|
||||
val locationManager: LocationManager = checkNotNull(context.getSystemService())
|
||||
val provider = locationManager.bestAvailableProvider()
|
||||
// Try to eagerly emit the last known location as fast as possible
|
||||
locationManager.getLastKnownLocation(provider)?.let { location ->
|
||||
trySendBlocking(
|
||||
Location(
|
||||
lat = location.latitude,
|
||||
lon = location.longitude,
|
||||
accuracy = location.accuracy
|
||||
)
|
||||
)
|
||||
}
|
||||
val locationListener = LocationListenerCompat { location ->
|
||||
trySendBlocking(
|
||||
Location(
|
||||
lat = location.latitude,
|
||||
lon = location.longitude,
|
||||
accuracy = location.accuracy
|
||||
)
|
||||
)
|
||||
}
|
||||
LocationManagerCompat.requestLocationUpdates(
|
||||
locationManager,
|
||||
provider,
|
||||
buildLocationRequest(),
|
||||
coroutineDispatchers.io.asExecutor(),
|
||||
locationListener,
|
||||
)
|
||||
awaitClose {
|
||||
LocationManagerCompat.removeUpdates(locationManager, locationListener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LocationManager.bestAvailableProvider(): String =
|
||||
checkNotNull(getProviders(true).maxByOrNull { providerPriority(it) }) {
|
||||
"No location provider available"
|
||||
}
|
||||
|
||||
private fun providerPriority(provider: String): Int = when (provider) {
|
||||
LocationManager.FUSED_PROVIDER -> 4
|
||||
LocationManager.GPS_PROVIDER -> 3
|
||||
LocationManager.NETWORK_PROVIDER -> 2
|
||||
LocationManager.PASSIVE_PROVIDER -> 1
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun buildLocationRequest() = LocationRequestCompat.Builder(1_000).apply {
|
||||
setMinUpdateIntervalMillis(1_000)
|
||||
}.build()
|
||||
|
|
@ -155,6 +155,8 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
|
|||
vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0"
|
||||
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
|
||||
statemachine = "com.freeletics.flowredux:compose:1.1.0"
|
||||
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
|
||||
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:1.0.0"
|
||||
|
||||
# Analytics
|
||||
posthog = "com.posthog.android:posthog:2.0.3"
|
||||
|
|
@ -186,6 +188,7 @@ android_application = { id = "com.android.application", version.ref = "android_g
|
|||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||
kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
anvil = { id = "com.squareup.anvil", version.ref = "anvil" }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:407a17fca1405575861b7c8861222a5d529778eeaeb904c3058fe19ff9f809fc
|
||||
size 143328
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:95068257a39fc8a693adce87c54b605be395de5fd639ee00b7d34dde5bce568a
|
||||
size 147055
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:819c585286d2ef5c7064766d537b9da62be54b67340a5a7a44e94dcf53b1caf4
|
||||
size 277318
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c0e68e68e1cf4f735671b5abcfb4e0c936ca1a00ead817e8f010d39f5b753252
|
||||
size 280801
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5d34ac54f8c46bc3752366adeefb0952b23f052f9359b48864a6a43455334a6d
|
||||
size 4965
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
|
||||
size 4457
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:407a17fca1405575861b7c8861222a5d529778eeaeb904c3058fe19ff9f809fc
|
||||
size 143328
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:819c585286d2ef5c7064766d537b9da62be54b67340a5a7a44e94dcf53b1caf4
|
||||
size 277318
|
||||
Loading…
Add table
Add a link
Reference in a new issue