Merge pull request #5479 from vector-im/feature/mna/PSF-735-pinned-location

#5417: Pinned location sharing
This commit is contained in:
Maxime NATUREL 2022-03-15 17:40:21 +01:00 committed by GitHub
commit 9ef235f3d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 540 additions and 67 deletions

1
changelog.d/5417.feature Normal file
View File

@ -0,0 +1 @@
Add ability to pin a location on map for sharing

View File

@ -64,4 +64,7 @@
<!-- Location sharing -->
<dimen name="location_sharing_option_default_padding">10dp</dimen>
<dimen name="location_sharing_locate_button_margin_vertical">16dp</dimen>
<dimen name="location_sharing_locate_button_margin_horizontal">12dp</dimen>
<dimen name="location_sharing_compass_button_margin_horizontal">8dp</dimen>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MapTilerMapView">
<attr name="showLocateButton" format="boolean" />
</declare-styleable>
</resources>

View File

@ -21,5 +21,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LocationAsset(
@Json(name = "type") val type: LocationAssetType? = null
@Json(name = "type") val type: String? = null
)

View File

@ -16,11 +16,20 @@
package org.matrix.android.sdk.api.session.room.model.message
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Define what particular asset is being referred to.
* We don't use enum type since it is not limited to a specific set of values.
* The way this type should be interpreted in client side is described in
* [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md)
*/
object LocationAssetType {
/**
* Used for user location sharing.
**/
const val SELF = "m.self"
@JsonClass(generateAdapter = false)
enum class LocationAssetType {
@Json(name = "m.self")
SELF
/**
* Used for pin drop location sharing.
**/
const val PIN = "m.pin"
}

View File

@ -42,7 +42,7 @@ data class MessageLocationContent(
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null,
/**
* See https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md
* See [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md)
*/
@Json(name = "org.matrix.msc3488.location") val unstableLocationInfo: LocationInfo? = null,
@Json(name = "m.location") val locationInfo: LocationInfo? = null,
@ -54,10 +54,11 @@ data class MessageLocationContent(
@Json(name = "org.matrix.msc1767.text") val unstableText: String? = null,
@Json(name = "m.text") val text: String? = null,
/**
* m.asset defines a generic asset that can be used for location tracking but also in other places like
* Defines a generic asset that can be used for location tracking but also in other places like
* inventories, geofencing, checkins/checkouts etc.
* It should contain a mandatory namespaced type key defining what particular asset is being referred to.
* For the purposes of user location tracking m.self should be used in order to avoid duplicating the mxid.
* See [MSC3488](https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md)
*/
@Json(name = "org.matrix.msc3488.asset") val unstableLocationAsset: LocationAsset? = null,
@Json(name = "m.asset") val locationAsset: LocationAsset? = null

View File

@ -142,8 +142,9 @@ interface SendService {
* @param latitude required latitude of the location
* @param longitude required longitude of the location
* @param uncertainty Accuracy of the location in meters
* @param isUserLocation indicates whether the location data corresponds to the user location or not
*/
fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?): Cancelable
fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?, isUserLocation: Boolean): Cancelable
/**
* Remove this failed message from the timeline

View File

@ -128,8 +128,8 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) }
}
override fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?): Cancelable {
return localEchoEventFactory.createLocationEvent(roomId, latitude, longitude, uncertainty)
override fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?, isUserLocation: Boolean): Cancelable {
return localEchoEventFactory.createLocationEvent(roomId, latitude, longitude, uncertainty, isUserLocation)
.also { createLocalEcho(it) }
.let { sendEvent(it) }
}

View File

@ -227,13 +227,15 @@ internal class LocalEchoEventFactory @Inject constructor(
fun createLocationEvent(roomId: String,
latitude: Double,
longitude: Double,
uncertainty: Double?): Event {
uncertainty: Double?,
isUserLocation: Boolean): Event {
val geoUri = buildGeoUri(latitude, longitude, uncertainty)
val assetType = if (isUserLocation) LocationAssetType.SELF else LocationAssetType.PIN
val content = MessageLocationContent(
geoUri = geoUri,
body = geoUri,
unstableLocationInfo = LocationInfo(geoUri = geoUri, description = geoUri),
unstableLocationAsset = LocationAsset(type = LocationAssetType.SELF),
unstableLocationAsset = LocationAsset(type = assetType),
unstableTs = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
unstableText = geoUri
)

View File

@ -123,7 +123,7 @@ class LocationPreviewFragment @Inject constructor(
views.mapView.render(
MapState(
zoomOnlyOnce = true,
pinLocationData = location,
userLocationData = location,
pinId = args.locationOwnerId ?: DEFAULT_PIN_ID,
pinDrawable = pinDrawable
)

View File

@ -19,5 +19,8 @@ package im.vector.app.features.location
import im.vector.app.core.platform.VectorViewModelAction
sealed class LocationSharingAction : VectorViewModelAction {
object OnShareLocation : LocationSharingAction()
object CurrentUserLocationSharing : LocationSharingAction()
data class PinnedLocationSharing(val locationData: LocationData?) : LocationSharingAction()
data class LocationTargetChange(val locationData: LocationData) : LocationSharingAction()
object ZoomToUserLocation : LocationSharingAction()
}

View File

@ -16,6 +16,7 @@
package im.vector.app.features.location
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -44,7 +45,7 @@ class LocationSharingFragment @Inject constructor(
private val urlMapProvider: UrlMapProvider,
private val avatarRenderer: AvatarRenderer,
private val matrixItemColorProvider: MatrixItemColorProvider
) : VectorBaseFragment<FragmentLocationSharingBinding>() {
) : VectorBaseFragment<FragmentLocationSharingBinding>(), LocationTargetChangeListener {
private val viewModel: LocationSharingViewModel by fragmentViewModel()
@ -64,15 +65,20 @@ class LocationSharingFragment @Inject constructor(
views.mapView.onCreate(savedInstanceState)
lifecycleScope.launchWhenCreated {
views.mapView.initialize(urlMapProvider.getMapUrl())
views.mapView.initialize(
url = urlMapProvider.getMapUrl(),
locationTargetChangeListener = this@LocationSharingFragment
)
}
initLocateButton()
initOptionsPicker()
viewModel.observeViewEvents {
when (it) {
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
LocationSharingViewEvents.Close -> activity?.finish()
is LocationSharingViewEvents.ZoomToUserLocation -> handleZoomToUserLocationEvent(it)
}.exhaustive
}
}
@ -113,10 +119,17 @@ class LocationSharingFragment @Inject constructor(
super.onDestroy()
}
override fun onLocationTargetChange(target: LocationData) {
viewModel.handle(LocationSharingAction.LocationTargetChange(target))
}
override fun invalidate() = withState(viewModel) { state ->
views.mapView.render(state.toMapState())
views.shareLocationGpsLoading.isGone = state.lastKnownLocation != null
updateMap(state)
updateUserAvatar(state.userItem)
if (state.locationTargetDrawable != null) {
updateLocationTargetPin(state.locationTargetDrawable)
}
views.shareLocationGpsLoading.isGone = state.lastKnownUserLocation != null
}
private fun handleLocationNotAvailableError() {
@ -130,21 +143,52 @@ class LocationSharingFragment @Inject constructor(
.show()
}
private fun initLocateButton() {
views.mapView.locateButton.setOnClickListener {
viewModel.handle(LocationSharingAction.ZoomToUserLocation)
}
}
private fun handleZoomToUserLocationEvent(event: LocationSharingViewEvents.ZoomToUserLocation) {
views.mapView.zoomToLocation(event.userLocation.latitude, event.userLocation.longitude)
}
private fun initOptionsPicker() {
// TODO
// change the options dynamically depending on the current chosen location
views.shareLocationOptionsPicker.render(LocationSharingOption.USER_CURRENT)
// set no option at start
views.shareLocationOptionsPicker.render()
views.shareLocationOptionsPicker.optionPinned.debouncedClicks {
// TODO
val targetLocation = views.mapView.getLocationOfMapCenter()
viewModel.handle(LocationSharingAction.PinnedLocationSharing(targetLocation))
}
views.shareLocationOptionsPicker.optionUserCurrent.debouncedClicks {
viewModel.handle(LocationSharingAction.OnShareLocation)
viewModel.handle(LocationSharingAction.CurrentUserLocationSharing)
}
views.shareLocationOptionsPicker.optionUserLive.debouncedClicks {
// TODO
}
}
private fun updateMap(state: LocationSharingViewState) {
// first, update the options view
when (state.areTargetAndUserLocationEqual) {
// TODO activate USER_LIVE option when implemented
true -> views.shareLocationOptionsPicker.render(
LocationSharingOption.USER_CURRENT
)
false -> views.shareLocationOptionsPicker.render(
LocationSharingOption.PINNED
)
else -> views.shareLocationOptionsPicker.render()
}
// then, update the map using the height of the options view after it has been rendered
views.shareLocationOptionsPicker.post {
val mapState = state
.toMapState()
.copy(logoMarginBottom = views.shareLocationOptionsPicker.height)
views.mapView.render(mapState)
}
}
private fun updateUserAvatar(userItem: MatrixItem.UserItem?) {
userItem?.takeUnless { hasRenderedUserAvatar }
?.let {
@ -154,4 +198,8 @@ class LocationSharingFragment @Inject constructor(
views.shareLocationOptionsPicker.optionUserCurrent.setIconBackgroundTint(tintColor)
}
}
private fun updateLocationTargetPin(drawable: Drawable) {
views.shareLocationPin.setImageDrawable(drawable)
}
}

View File

@ -21,4 +21,5 @@ import im.vector.app.core.platform.VectorViewEvents
sealed class LocationSharingViewEvents : VectorViewEvents {
object Close : LocationSharingViewEvents()
object LocationNotAvailableError : LocationSharingViewEvents()
data class ZoomToUserLocation(val userLocation: LocationData) : LocationSharingViewEvents()
}

View File

@ -16,6 +16,7 @@
package im.vector.app.features.location
import android.graphics.drawable.Drawable
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -25,18 +26,36 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.location.domain.usecase.CompareLocationsUseCase
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.util.toMatrixItem
/**
* Sampling period to compare target location and user location.
*/
private const val TARGET_LOCATION_CHANGE_SAMPLING_PERIOD_IN_MS = 100L
class LocationSharingViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationSharingViewState,
private val locationTracker: LocationTracker,
private val locationPinProvider: LocationPinProvider,
private val session: Session
private val session: Session,
private val compareLocationsUseCase: CompareLocationsUseCase
) : VectorViewModel<LocationSharingViewState, LocationSharingAction, LocationSharingViewEvents>(initialState), LocationTracker.Callback {
private val room = session.getRoom(initialState.roomId)!!
private val locationTargetFlow = MutableSharedFlow<LocationData>()
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LocationSharingViewModel, LocationSharingViewState> {
override fun create(initialState: LocationSharingViewState): LocationSharingViewModel
@ -47,23 +66,49 @@ class LocationSharingViewModel @AssistedInject constructor(
init {
locationTracker.start(this)
setUserItem()
createPin()
updatePin()
compareTargetAndUserLocation()
}
private fun setUserItem() {
setState { copy(userItem = session.getUser(session.myUserId)?.toMatrixItem()) }
}
private fun createPin() {
locationPinProvider.create(session.myUserId) {
setState {
copy(
pinDrawable = it
)
private fun updatePin(isUserPin: Boolean? = true) {
if (isUserPin.orFalse()) {
locationPinProvider.create(userId = session.myUserId) {
updatePinDrawableInState(it)
}
} else {
locationPinProvider.create(userId = null) {
updatePinDrawableInState(it)
}
}
}
private fun updatePinDrawableInState(drawable: Drawable) {
setState {
copy(
locationTargetDrawable = drawable
)
}
}
private fun compareTargetAndUserLocation() {
locationTargetFlow
.sample(TARGET_LOCATION_CHANGE_SAMPLING_PERIOD_IN_MS)
.map { compareTargetLocation(it) }
.distinctUntilChanged()
.onEach { setState { copy(areTargetAndUserLocationEqual = it) } }
.onEach { updatePin(isUserPin = it) }
.launchIn(viewModelScope)
}
private suspend fun compareTargetLocation(targetLocation: LocationData): Boolean? {
return awaitState().lastKnownUserLocation
?.let { userLocation -> compareLocationsUseCase.execute(userLocation, targetLocation) }
}
override fun onCleared() {
super.onCleared()
locationTracker.stop()
@ -71,16 +116,28 @@ class LocationSharingViewModel @AssistedInject constructor(
override fun handle(action: LocationSharingAction) {
when (action) {
LocationSharingAction.OnShareLocation -> handleShareLocation()
LocationSharingAction.CurrentUserLocationSharing -> handleCurrentUserLocationSharingAction()
is LocationSharingAction.PinnedLocationSharing -> handlePinnedLocationSharingAction(action)
is LocationSharingAction.LocationTargetChange -> handleLocationTargetChangeAction(action)
LocationSharingAction.ZoomToUserLocation -> handleZoomToUserLocationAction()
}.exhaustive
}
private fun handleShareLocation() = withState { state ->
state.lastKnownLocation?.let { location ->
private fun handleCurrentUserLocationSharingAction() = withState { state ->
shareLocation(state.lastKnownUserLocation, isUserLocation = true)
}
private fun handlePinnedLocationSharingAction(action: LocationSharingAction.PinnedLocationSharing) {
shareLocation(action.locationData, isUserLocation = false)
}
private fun shareLocation(locationData: LocationData?, isUserLocation: Boolean) {
locationData?.let { location ->
room.sendLocation(
latitude = location.latitude,
longitude = location.longitude,
uncertainty = location.uncertainty
uncertainty = location.uncertainty,
isUserLocation = isUserLocation
)
_viewEvents.post(LocationSharingViewEvents.Close)
} ?: run {
@ -88,9 +145,27 @@ class LocationSharingViewModel @AssistedInject constructor(
}
}
private fun handleLocationTargetChangeAction(action: LocationSharingAction.LocationTargetChange) {
viewModelScope.launch {
locationTargetFlow.emit(action.locationData)
}
}
private fun handleZoomToUserLocationAction() = withState { state ->
state.lastKnownUserLocation?.let { location ->
_viewEvents.post(LocationSharingViewEvents.ZoomToUserLocation(location))
}
}
override fun onLocationUpdate(locationData: LocationData) {
setState {
copy(lastKnownLocation = locationData)
copy(lastKnownUserLocation = locationData)
}
viewModelScope.launch {
// recompute location comparison using last received target location
locationTargetFlow.lastOrNull()?.let {
locationTargetFlow.emit(it)
}
}
}

View File

@ -20,6 +20,7 @@ import android.graphics.drawable.Drawable
import androidx.annotation.StringRes
import com.airbnb.mvrx.MavericksState
import im.vector.app.R
import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.util.MatrixItem
enum class LocationSharingMode(@StringRes val titleRes: Int) {
@ -31,8 +32,9 @@ data class LocationSharingViewState(
val roomId: String,
val mode: LocationSharingMode,
val userItem: MatrixItem.UserItem? = null,
val lastKnownLocation: LocationData? = null,
val pinDrawable: Drawable? = null
val areTargetAndUserLocationEqual: Boolean? = null,
val lastKnownUserLocation: LocationData? = null,
val locationTargetDrawable: Drawable? = null
) : MavericksState {
constructor(locationSharingArgs: LocationSharingArgs) : this(
@ -43,7 +45,9 @@ data class LocationSharingViewState(
fun LocationSharingViewState.toMapState() = MapState(
zoomOnlyOnce = true,
pinLocationData = lastKnownLocation,
userLocationData = lastKnownUserLocation,
pinId = DEFAULT_PIN_ID,
pinDrawable = pinDrawable
pinDrawable = null,
// show the map pin only when target location and user location are not equal
showPin = areTargetAndUserLocationEqual.orTrue().not()
)

View File

@ -0,0 +1,21 @@
/*
* 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.
*/
package im.vector.app.features.location
interface LocationTargetChangeListener {
fun onLocationTargetChange(target: LocationData)
}

View File

@ -17,10 +17,13 @@
package im.vector.app.features.location
import android.graphics.drawable.Drawable
import androidx.annotation.Px
data class MapState(
val zoomOnlyOnce: Boolean,
val pinLocationData: LocationData? = null,
val userLocationData: LocationData? = null,
val pinId: String,
val pinDrawable: Drawable? = null
val pinDrawable: Drawable? = null,
val showPin: Boolean = true,
@Px val logoMarginBottom: Int = 0
)

View File

@ -17,7 +17,14 @@
package im.vector.app.features.location
import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.view.Gravity
import android.widget.ImageView
import androidx.core.content.ContextCompat
import androidx.core.view.marginBottom
import androidx.core.view.marginTop
import androidx.core.view.updateLayoutParams
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.maps.MapView
@ -26,6 +33,7 @@ import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property
import im.vector.app.R
import timber.log.Timber
class MapTilerMapView @JvmOverloads constructor(
@ -42,24 +50,100 @@ class MapTilerMapView @JvmOverloads constructor(
val style: Style
)
private val userLocationDrawable by lazy {
ContextCompat.getDrawable(context, R.drawable.ic_location_user)
}
val locateButton by lazy { createLocateButton() }
private var mapRefs: MapRefs? = null
private var initZoomDone = false
private var showLocationButton = false
init {
context.theme.obtainStyledAttributes(
attrs,
R.styleable.MapTilerMapView,
0,
0
).run {
try {
setLocateButtonVisibility(this)
} finally {
recycle()
}
}
}
private fun setLocateButtonVisibility(typedArray: TypedArray) {
showLocationButton = typedArray.getBoolean(R.styleable.MapTilerMapView_showLocateButton, false)
}
/**
* For location fragments
*/
fun initialize(url: String) {
fun initialize(
url: String,
locationTargetChangeListener: LocationTargetChangeListener? = null
) {
Timber.d("## Location: initialize")
getMapAsync { map ->
map.setStyle(url) { style ->
mapRefs = MapRefs(
map,
SymbolManager(this, map, style),
style
)
pendingState?.let { render(it) }
pendingState = null
initMapStyle(map, url)
initLocateButton(map)
notifyLocationOfMapCenter(locationTargetChangeListener)
listenCameraMove(map, locationTargetChangeListener)
}
}
private fun initMapStyle(map: MapboxMap, url: String) {
map.setStyle(url) { style ->
mapRefs = MapRefs(
map,
SymbolManager(this, map, style),
style
)
pendingState?.let { render(it) }
pendingState = null
}
}
private fun initLocateButton(map: MapboxMap) {
if (showLocationButton) {
addView(locateButton)
adjustCompassButton(map)
}
}
private fun createLocateButton(): ImageView =
ImageView(context).apply {
setImageDrawable(ContextCompat.getDrawable(context, R.drawable.btn_locate))
contentDescription = context.getString(R.string.a11y_location_share_locate_button)
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
updateLayoutParams<MarginLayoutParams> {
val marginHorizontal = context.resources.getDimensionPixelOffset(R.dimen.location_sharing_locate_button_margin_horizontal)
val marginVertical = context.resources.getDimensionPixelOffset(R.dimen.location_sharing_locate_button_margin_vertical)
setMargins(marginHorizontal, marginVertical, marginHorizontal, marginVertical)
}
updateLayoutParams<LayoutParams> {
gravity = Gravity.TOP or Gravity.END
}
}
private fun adjustCompassButton(map: MapboxMap) {
locateButton.post {
val marginTop = locateButton.height + locateButton.marginTop + locateButton.marginBottom
val marginRight = context.resources.getDimensionPixelOffset(R.dimen.location_sharing_compass_button_margin_horizontal)
map.uiSettings.setCompassMargins(0, marginTop, marginRight, 0)
}
}
private fun listenCameraMove(map: MapboxMap, locationTargetChangeListener: LocationTargetChangeListener?) {
map.addOnCameraMoveListener {
notifyLocationOfMapCenter(locationTargetChangeListener)
}
}
private fun notifyLocationOfMapCenter(locationTargetChangeListener: LocationTargetChangeListener?) {
getLocationOfMapCenter()?.let { target ->
locationTargetChangeListener?.onLocationTargetChange(target)
}
}
@ -68,34 +152,48 @@ class MapTilerMapView @JvmOverloads constructor(
pendingState = state
}
state.pinDrawable?.let { pinDrawable ->
safeMapRefs.map.uiSettings.setLogoMargins(0, 0, 0, state.logoMarginBottom)
val pinDrawable = state.pinDrawable ?: userLocationDrawable
pinDrawable?.let { drawable ->
if (!safeMapRefs.style.isFullyLoaded ||
safeMapRefs.style.getImage(state.pinId) == null) {
safeMapRefs.style.addImage(state.pinId, pinDrawable)
safeMapRefs.style.addImage(state.pinId, drawable)
}
}
state.pinLocationData?.let { locationData ->
state.userLocationData?.let { locationData ->
if (!initZoomDone || !state.zoomOnlyOnce) {
zoomToLocation(locationData.latitude, locationData.longitude)
initZoomDone = true
}
safeMapRefs.symbolManager.deleteAll()
safeMapRefs.symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(locationData.latitude, locationData.longitude))
.withIconImage(state.pinId)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
)
if (pinDrawable != null && state.showPin) {
safeMapRefs.symbolManager.create(
SymbolOptions()
.withLatLng(LatLng(locationData.latitude, locationData.longitude))
.withIconImage(state.pinId)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
)
}
}
}
private fun zoomToLocation(latitude: Double, longitude: Double) {
fun zoomToLocation(latitude: Double, longitude: Double) {
Timber.d("## Location: zoomToLocation")
mapRefs?.map?.cameraPosition = CameraPosition.Builder()
.target(LatLng(latitude, longitude))
.zoom(INITIAL_MAP_ZOOM_IN_PREVIEW)
.build()
}
fun getLocationOfMapCenter(): LocationData? =
mapRefs?.map?.cameraPosition?.target?.let { target ->
LocationData(
latitude = target.latitude,
longitude = target.longitude,
uncertainty = null
)
}
}

View File

@ -0,0 +1,48 @@
/*
* 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.
*/
package im.vector.app.features.location.domain.usecase
import com.mapbox.mapboxsdk.geometry.LatLng
import im.vector.app.features.location.LocationData
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject
/**
* Threshold in meters to consider 2 locations as equal.
*/
private const val SAME_LOCATION_THRESHOLD_IN_METERS = 5
/**
* Use case to check if 2 locations can be considered as equal.
*/
class CompareLocationsUseCase @Inject constructor(
private val session: Session
) {
/**
* Compare the 2 given locations.
* @return true when they are really close and could be considered as the same location, false otherwise
*/
suspend fun execute(location1: LocationData, location2: LocationData): Boolean =
withContext(session.coroutineDispatchers.io) {
val loc1 = LatLng(location1.latitude, location1.longitude)
val loc2 = LatLng(location2.latitude, location2.longitude)
val distance = loc1.distanceTo(loc2)
distance <= SAME_LOCATION_THRESHOLD_IN_METERS
}
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/palette_white" />
<corners android:radius="4dp" />
</shape>
</item>
<item android:drawable="?selectableItemBackground" />
<item
android:bottom="8dp"
android:drawable="@drawable/ic_locate"
android:left="8dp"
android:right="8dp"
android:top="8dp" />
</layer-list>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M11,7C8.79,7 7,8.79 7,11C7,13.21 8.79,15 11,15C13.21,15 15,13.21 15,11C15,8.79 13.21,7 11,7ZM19.94,10C19.48,5.83 16.17,2.52 12,2.06V1C12,0.45 11.55,0 11,0C10.45,0 10,0.45 10,1V2.06C5.83,2.52 2.52,5.83 2.06,10H1C0.45,10 0,10.45 0,11C0,11.55 0.45,12 1,12H2.06C2.52,16.17 5.83,19.48 10,19.94V21C10,21.55 10.45,22 11,22C11.55,22 12,21.55 12,21V19.94C16.17,19.48 19.48,16.17 19.94,12H21C21.55,12 22,11.55 22,11C22,10.45 21.55,10 21,10H19.94ZM11,18C7.13,18 4,14.87 4,11C4,7.13 7.13,4 11,4C14.87,4 18,7.13 18,11C18,14.87 14.87,18 11,18Z"
android:fillColor="#0DBD8B"/>
</vector>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<size
android:width="13dp"
android:height="13dp" />
<solid android:color="?colorPrimary" />
<stroke
android:width="2dp"
android:color="@color/palette_white" />
</shape>
</item>
</layer-list>

View File

@ -8,6 +8,7 @@
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:mapbox_renderTextureMode="true" />
app:mapbox_renderTextureMode="true"
app:showLocateButton="false" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -7,13 +7,34 @@
<im.vector.app.features.location.MapTilerMapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/shareLocationOptionsPicker"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:mapbox_renderTextureMode="true"
app:showLocateButton="true"
tools:background="#4F00" />
<ImageView
android:id="@+id/shareLocationPin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/a11y_location_share_pin_on_map"
app:layout_constraintBottom_toTopOf="@id/shareLocationMapCenter"
app:layout_constraintEnd_toEndOf="@id/mapView"
app:layout_constraintStart_toStartOf="@id/mapView" />
<ViewStub
android:id="@+id/shareLocationMapCenter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="@id/mapView"
app:layout_constraintEnd_toEndOf="@id/mapView"
app:layout_constraintStart_toStartOf="@id/mapView"
app:layout_constraintTop_toTopOf="@id/mapView" />
<im.vector.app.features.location.option.LocationSharingOptionPickerView
android:id="@+id/shareLocationOptionsPicker"
android:layout_width="0dp"

View File

@ -2929,6 +2929,8 @@
<string name="a11y_static_map_image">Map</string>
<!-- TODO delete -->
<string name="location_share" tools:ignore="UnusedResources">Share location</string>
<string name="a11y_location_share_pin_on_map">Pin of selected location on map</string>
<string name="a11y_location_share_locate_button">Zoom to current location</string>
<string name="location_share_option_user_current">Share my current location</string>
<string name="a11y_location_share_option_user_current_icon">Share my current location</string>
<string name="location_share_option_user_live">Share live location</string>

View File

@ -0,0 +1,83 @@
/*
* 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.
*/
package im.vector.app.features.location.domain.usecase
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.location.LocationData
import im.vector.app.test.fakes.FakeSession
import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.OverrideMockKs
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class CompareLocationsUseCaseTest {
@get:Rule
val mvRxTestRule = MvRxTestRule()
private val session = FakeSession()
@OverrideMockKs
lateinit var compareLocationsUseCase: CompareLocationsUseCase
@Before
fun setUp() {
MockKAnnotations.init(this)
}
@Test
fun `given 2 very near locations when calling execute then these locations are considered as equal`() = runBlockingTest {
// Given
val location1 = LocationData(
latitude = 48.858269,
longitude = 2.294551,
uncertainty = null
)
val location2 = LocationData(
latitude = 48.858275,
longitude = 2.294547,
uncertainty = null
)
// When
val areEqual = compareLocationsUseCase.execute(location1, location2)
// Then
assert(areEqual)
}
@Test
fun `given 2 far away locations when calling execute then these locations are considered as not equal`() = runBlockingTest {
// Given
val location1 = LocationData(
latitude = 48.858269,
longitude = 2.294551,
uncertainty = null
)
val location2 = LocationData(
latitude = 48.861777,
longitude = 2.289348,
uncertainty = null
)
// When
val areEqual = compareLocationsUseCase.execute(location1, location2)
// Then
assert(areEqual.not())
}
}