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()) + } +}