diff --git a/changelog.d/5417.feature b/changelog.d/5417.feature
new file mode 100644
index 0000000000..8b64f9fc7f
--- /dev/null
+++ b/changelog.d/5417.feature
@@ -0,0 +1 @@
+Add ability to pin a location on map for sharing
diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml
index 6737f4faf1..600c73c878 100644
--- a/library/ui-styles/src/main/res/values/dimens.xml
+++ b/library/ui-styles/src/main/res/values/dimens.xml
@@ -64,4 +64,7 @@
10dp
+ 16dp
+ 12dp
+ 8dp
diff --git a/library/ui-styles/src/main/res/values/stylable_map_tiler_map_view.xml b/library/ui-styles/src/main/res/values/stylable_map_tiler_map_view.xml
new file mode 100644
index 0000000000..a7c45918af
--- /dev/null
+++ b/library/ui-styles/src/main/res/values/stylable_map_tiler_map_view.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt
index e8b3cf2488..35fa555a5b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt
@@ -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
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt
index ef40e21c47..f7d82d4b40 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt
@@ -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"
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt
index 84bf5cf7b7..2052133b06 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt
@@ -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
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt
index 913dbfd010..9f8b1d93d7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt
@@ -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
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt
index 28c17f38b6..31c7254ed5 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt
@@ -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) }
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
index bec0ce97dc..0ba95cc1fb 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
@@ -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
)
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt
index db837f4823..5d823e53a6 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt
@@ -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
)
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt
index 01319ef6c7..ec47c23ea7 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt
@@ -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()
}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt
index b1033f2797..e9e96e676c 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt
@@ -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() {
+) : VectorBaseFragment(), 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)
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt
index 743daaf5e0..8d31db1119 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt
@@ -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()
}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt
index 989ec255e5..25bc482412 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt
@@ -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(initialState), LocationTracker.Callback {
private val room = session.getRoom(initialState.roomId)!!
+ private val locationTargetFlow = MutableSharedFlow()
+
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory {
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)
+ }
}
}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt
index e63206f515..ee5ba402e2 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt
@@ -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()
)
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTargetChangeListener.kt b/vector/src/main/java/im/vector/app/features/location/LocationTargetChangeListener.kt
new file mode 100644
index 0000000000..07e3afb399
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/LocationTargetChangeListener.kt
@@ -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)
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/MapState.kt b/vector/src/main/java/im/vector/app/features/location/MapState.kt
index d001457e4f..c4325291a8 100644
--- a/vector/src/main/java/im/vector/app/features/location/MapState.kt
+++ b/vector/src/main/java/im/vector/app/features/location/MapState.kt
@@ -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
)
diff --git a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt
index dd80f701f6..e3206e231d 100644
--- a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt
+++ b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt
@@ -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 {
+ 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 {
+ 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
+ )
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCase.kt b/vector/src/main/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCase.kt
new file mode 100644
index 0000000000..91738541be
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCase.kt
@@ -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
+ }
+}
diff --git a/vector/src/main/res/drawable/btn_locate.xml b/vector/src/main/res/drawable/btn_locate.xml
new file mode 100644
index 0000000000..583b3a97ea
--- /dev/null
+++ b/vector/src/main/res/drawable/btn_locate.xml
@@ -0,0 +1,17 @@
+
+
+ -
+
+
+
+
+
+
+
+
+
diff --git a/vector/src/main/res/drawable/ic_locate.xml b/vector/src/main/res/drawable/ic_locate.xml
new file mode 100644
index 0000000000..784665fcdd
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_locate.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/vector/src/main/res/drawable/ic_location_user.xml b/vector/src/main/res/drawable/ic_location_user.xml
new file mode 100644
index 0000000000..dc6baca65e
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_location_user.xml
@@ -0,0 +1,14 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/vector/src/main/res/layout/fragment_location_preview.xml b/vector/src/main/res/layout/fragment_location_preview.xml
index c2b3bdd739..5499e5b427 100644
--- a/vector/src/main/res/layout/fragment_location_preview.xml
+++ b/vector/src/main/res/layout/fragment_location_preview.xml
@@ -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" />
-
\ No newline at end of file
+
diff --git a/vector/src/main/res/layout/fragment_location_sharing.xml b/vector/src/main/res/layout/fragment_location_sharing.xml
index 3d07e4438d..cd15f418ea 100644
--- a/vector/src/main/res/layout/fragment_location_sharing.xml
+++ b/vector/src/main/res/layout/fragment_location_sharing.xml
@@ -7,13 +7,34 @@
+
+
+
+
Map
Share location
+ Pin of selected location on map
+ Zoom to current location
Share my current location
Share my current location
Share live location
diff --git a/vector/src/test/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCaseTest.kt
new file mode 100644
index 0000000000..015a27b0c8
--- /dev/null
+++ b/vector/src/test/java/im/vector/app/features/location/domain/usecase/CompareLocationsUseCaseTest.kt
@@ -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())
+ }
+}