Merge pull request #8144 from vector-im/feature/mna/user-location-in-loc-sharing
[Location sharing] Show own location in map views
This commit is contained in:
commit
6bd150d4cd
|
@ -0,0 +1 @@
|
|||
[Location sharing] Show own location in map views
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.features.location
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import im.vector.app.R
|
||||
|
||||
fun Fragment.showUserLocationNotAvailableErrorDialog(onConfirmListener: () -> Unit) {
|
||||
MaterialAlertDialogBuilder(requireActivity())
|
||||
.setTitle(R.string.location_not_available_dialog_title)
|
||||
.setMessage(R.string.location_not_available_dialog_content)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
onConfirmListener()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
|
@ -176,14 +176,7 @@ class LocationSharingFragment :
|
|||
}
|
||||
|
||||
private fun handleLocationNotAvailableError() {
|
||||
MaterialAlertDialogBuilder(requireActivity())
|
||||
.setTitle(R.string.location_not_available_dialog_title)
|
||||
.setMessage(R.string.location_not_available_dialog_content)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
locationSharingNavigator.quit()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
showUserLocationNotAvailableErrorDialog { locationSharingNavigator.quit() }
|
||||
}
|
||||
|
||||
private fun handleLiveLocationSharingNotEnoughPermission() {
|
||||
|
|
|
@ -47,7 +47,7 @@ data class LocationSharingViewState(
|
|||
|
||||
fun LocationSharingViewState.toMapState() = MapState(
|
||||
zoomOnlyOnce = true,
|
||||
userLocationData = lastKnownUserLocation,
|
||||
pinLocationData = lastKnownUserLocation,
|
||||
pinId = DEFAULT_PIN_ID,
|
||||
pinDrawable = null,
|
||||
// show the map pin only when target location and user location are not equal
|
||||
|
|
|
@ -66,6 +66,8 @@ class LocationTracker @Inject constructor(
|
|||
@VisibleForTesting
|
||||
var hasLocationFromGPSProvider = false
|
||||
|
||||
private var isStarted = false
|
||||
private var isStarting = false
|
||||
private var firstLocationHandled = false
|
||||
private val _locations = MutableSharedFlow<Location>(replay = 1)
|
||||
|
||||
|
@ -90,43 +92,48 @@ class LocationTracker @Inject constructor(
|
|||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
|
||||
fun start() {
|
||||
Timber.d("start()")
|
||||
if (!isStarting && !isStarted) {
|
||||
isStarting = true
|
||||
Timber.d("start()")
|
||||
|
||||
if (locationManager == null) {
|
||||
Timber.v("LocationManager is not available")
|
||||
onNoLocationProviderAvailable()
|
||||
return
|
||||
}
|
||||
if (locationManager == null) {
|
||||
Timber.v("LocationManager is not available")
|
||||
onNoLocationProviderAvailable()
|
||||
return
|
||||
}
|
||||
|
||||
val providers = locationManager.allProviders
|
||||
val providers = locationManager.allProviders
|
||||
|
||||
if (providers.isEmpty()) {
|
||||
Timber.v("There is no location provider available")
|
||||
onNoLocationProviderAvailable()
|
||||
} else {
|
||||
// Take GPS first
|
||||
providers.sortedByDescending(::getProviderPriority)
|
||||
.mapNotNull { provider ->
|
||||
Timber.d("track location using $provider")
|
||||
if (providers.isEmpty()) {
|
||||
Timber.v("There is no location provider available")
|
||||
onNoLocationProviderAvailable()
|
||||
} else {
|
||||
// Take GPS first
|
||||
providers.sortedByDescending(::getProviderPriority)
|
||||
.mapNotNull { provider ->
|
||||
Timber.d("track location using $provider")
|
||||
|
||||
locationManager.requestLocationUpdates(
|
||||
provider,
|
||||
minDurationToUpdateLocationMillis,
|
||||
MIN_DISTANCE_TO_UPDATE_LOCATION_METERS,
|
||||
this
|
||||
)
|
||||
locationManager.requestLocationUpdates(
|
||||
provider,
|
||||
minDurationToUpdateLocationMillis,
|
||||
MIN_DISTANCE_TO_UPDATE_LOCATION_METERS,
|
||||
this
|
||||
)
|
||||
|
||||
locationManager.getLastKnownLocation(provider)
|
||||
}
|
||||
.maxByOrNull { location -> location.time }
|
||||
?.let { latestKnownLocation ->
|
||||
if (buildMeta.lowPrivacyLoggingEnabled) {
|
||||
Timber.d("lastKnownLocation: $latestKnownLocation")
|
||||
} else {
|
||||
Timber.d("lastKnownLocation: ${latestKnownLocation.provider}")
|
||||
locationManager.getLastKnownLocation(provider)
|
||||
}
|
||||
notifyLocation(latestKnownLocation)
|
||||
}
|
||||
.maxByOrNull { location -> location.time }
|
||||
?.let { latestKnownLocation ->
|
||||
if (buildMeta.lowPrivacyLoggingEnabled) {
|
||||
Timber.d("lastKnownLocation: $latestKnownLocation")
|
||||
} else {
|
||||
Timber.d("lastKnownLocation: ${latestKnownLocation.provider}")
|
||||
}
|
||||
notifyLocation(latestKnownLocation)
|
||||
}
|
||||
}
|
||||
isStarted = true
|
||||
isStarting = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -148,6 +155,8 @@ class LocationTracker @Inject constructor(
|
|||
callbacks.clear()
|
||||
hasLocationFromGPSProvider = false
|
||||
hasLocationFromFusedProvider = false
|
||||
isStarting = false
|
||||
isStarted = false
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -21,9 +21,10 @@ import androidx.annotation.Px
|
|||
|
||||
data class MapState(
|
||||
val zoomOnlyOnce: Boolean,
|
||||
val userLocationData: LocationData? = null,
|
||||
val pinLocationData: LocationData? = null,
|
||||
val pinId: String,
|
||||
val pinDrawable: Drawable? = null,
|
||||
val showPin: Boolean = true,
|
||||
@Px val logoMarginBottom: Int = 0
|
||||
val userLocationData: LocationData? = null,
|
||||
@Px val logoMarginBottom: Int = 0,
|
||||
)
|
||||
|
|
|
@ -18,6 +18,7 @@ package im.vector.app.features.location
|
|||
|
||||
import android.content.Context
|
||||
import android.content.res.TypedArray
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.widget.ImageView
|
||||
|
@ -38,6 +39,8 @@ import im.vector.app.R
|
|||
import im.vector.app.core.utils.DimensionConverter
|
||||
import timber.log.Timber
|
||||
|
||||
private const val USER_PIN_ID = "user-pin-id"
|
||||
|
||||
class MapTilerMapView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
|
@ -101,9 +104,11 @@ class MapTilerMapView @JvmOverloads constructor(
|
|||
|
||||
private fun initMapStyle(map: MapboxMap, url: String) {
|
||||
map.setStyle(url) { style ->
|
||||
val symbolManager = SymbolManager(this, map, style)
|
||||
symbolManager.iconAllowOverlap = true
|
||||
mapRefs = MapRefs(
|
||||
map,
|
||||
SymbolManager(this, map, style),
|
||||
symbolManager,
|
||||
style
|
||||
)
|
||||
pendingState?.let { render(it) }
|
||||
|
@ -166,29 +171,43 @@ class MapTilerMapView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
val pinDrawable = state.pinDrawable ?: userLocationDrawable
|
||||
pinDrawable?.let { drawable ->
|
||||
if (!safeMapRefs.style.isFullyLoaded ||
|
||||
safeMapRefs.style.getImage(state.pinId) == null) {
|
||||
safeMapRefs.style.addImage(state.pinId, drawable.toBitmap())
|
||||
}
|
||||
}
|
||||
addImageToMapStyle(pinDrawable, state.pinId, safeMapRefs)
|
||||
|
||||
state.userLocationData?.let { locationData ->
|
||||
safeMapRefs.symbolManager.deleteAll()
|
||||
state.pinLocationData?.let { locationData ->
|
||||
if (!initZoomDone || !state.zoomOnlyOnce) {
|
||||
zoomToLocation(locationData)
|
||||
initZoomDone = true
|
||||
}
|
||||
|
||||
safeMapRefs.symbolManager.deleteAll()
|
||||
if (pinDrawable != null && state.showPin) {
|
||||
safeMapRefs.symbolManager.create(
|
||||
SymbolOptions()
|
||||
.withLatLng(LatLng(locationData.latitude, locationData.longitude))
|
||||
.withIconImage(state.pinId)
|
||||
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
|
||||
)
|
||||
createSymbol(locationData, state.pinId, safeMapRefs)
|
||||
}
|
||||
}
|
||||
|
||||
state.userLocationData?.let { locationData ->
|
||||
addImageToMapStyle(userLocationDrawable, USER_PIN_ID, safeMapRefs)
|
||||
if (userLocationDrawable != null) {
|
||||
createSymbol(locationData, USER_PIN_ID, safeMapRefs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addImageToMapStyle(image: Drawable?, imageId: String, mapRefs: MapRefs) {
|
||||
image?.let { drawable ->
|
||||
if (!mapRefs.style.isFullyLoaded || mapRefs.style.getImage(imageId) == null) {
|
||||
mapRefs.style.addImage(imageId, drawable.toBitmap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSymbol(locationData: LocationData, imageId: String, mapRefs: MapRefs) {
|
||||
mapRefs.symbolManager.create(
|
||||
SymbolOptions()
|
||||
.withLatLng(LatLng(locationData.latitude, locationData.longitude))
|
||||
.withIconImage(imageId)
|
||||
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
|
||||
)
|
||||
}
|
||||
|
||||
fun zoomToLocation(locationData: LocationData) {
|
||||
|
|
|
@ -23,4 +23,5 @@ sealed class LiveLocationMapAction : VectorViewModelAction {
|
|||
data class RemoveMapSymbol(val key: String) : LiveLocationMapAction()
|
||||
object StopSharing : LiveLocationMapAction()
|
||||
object ShowMapLoadingError : LiveLocationMapAction()
|
||||
object ZoomToUserLocation : LiveLocationMapAction()
|
||||
}
|
||||
|
|
|
@ -17,7 +17,10 @@
|
|||
package im.vector.app.features.location.live.map
|
||||
|
||||
import im.vector.app.core.platform.VectorViewEvents
|
||||
import im.vector.app.features.location.LocationData
|
||||
|
||||
sealed interface LiveLocationMapViewEvents : VectorViewEvents {
|
||||
data class Error(val error: Throwable) : LiveLocationMapViewEvents
|
||||
data class LiveLocationError(val error: Throwable) : LiveLocationMapViewEvents
|
||||
data class ZoomToUserLocation(val userLocation: LocationData) : LiveLocationMapViewEvents
|
||||
object UserLocationNotAvailableError : LiveLocationMapViewEvents
|
||||
}
|
||||
|
|
|
@ -24,6 +24,8 @@ import android.view.ViewGroup
|
|||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.marginBottom
|
||||
import androidx.core.view.marginTop
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.airbnb.mvrx.fragmentViewModel
|
||||
import com.airbnb.mvrx.withState
|
||||
|
@ -46,11 +48,17 @@ import im.vector.app.core.extensions.addChildFragment
|
|||
import im.vector.app.core.extensions.cleanup
|
||||
import im.vector.app.core.extensions.configureWith
|
||||
import im.vector.app.core.platform.VectorBaseFragment
|
||||
import im.vector.app.core.resources.DrawableProvider
|
||||
import im.vector.app.core.utils.DimensionConverter
|
||||
import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING
|
||||
import im.vector.app.core.utils.checkPermissions
|
||||
import im.vector.app.core.utils.onPermissionDeniedDialog
|
||||
import im.vector.app.core.utils.openLocation
|
||||
import im.vector.app.core.utils.registerForPermissionsResult
|
||||
import im.vector.app.databinding.FragmentLiveLocationMapViewBinding
|
||||
import im.vector.app.features.location.LocationData
|
||||
import im.vector.app.features.location.UrlMapProvider
|
||||
import im.vector.app.features.location.showUserLocationNotAvailableErrorDialog
|
||||
import im.vector.app.features.location.zoomToBounds
|
||||
import im.vector.app.features.location.zoomToLocation
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -58,6 +66,8 @@ import timber.log.Timber
|
|||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val USER_LOCATION_PIN_ID = "user-location-pin-id"
|
||||
|
||||
/**
|
||||
* Screen showing a map with all the current users sharing their live location in a room.
|
||||
*/
|
||||
|
@ -68,6 +78,7 @@ class LiveLocationMapViewFragment :
|
|||
@Inject lateinit var urlMapProvider: UrlMapProvider
|
||||
@Inject lateinit var bottomSheetController: LiveLocationBottomSheetController
|
||||
@Inject lateinit var dimensionConverter: DimensionConverter
|
||||
@Inject lateinit var drawableProvider: DrawableProvider
|
||||
|
||||
private val viewModel: LiveLocationMapViewModel by fragmentViewModel()
|
||||
|
||||
|
@ -75,7 +86,7 @@ class LiveLocationMapViewFragment :
|
|||
private var mapView: MapView? = null
|
||||
private var symbolManager: SymbolManager? = null
|
||||
private var mapStyle: Style? = null
|
||||
private val pendingLiveLocations = mutableListOf<UserLiveLocationViewState>()
|
||||
private val userLocationDrawable by lazy { drawableProvider.getDrawable(R.drawable.ic_location_user) }
|
||||
private var isMapFirstUpdate = true
|
||||
private var onSymbolClickListener: OnSymbolClickListener? = null
|
||||
private var mapLoadingErrorListener: MapView.OnDidFailLoadingMapListener? = null
|
||||
|
@ -88,6 +99,7 @@ class LiveLocationMapViewFragment :
|
|||
super.onViewCreated(view, savedInstanceState)
|
||||
observeViewEvents()
|
||||
setupMap()
|
||||
initLocateButton()
|
||||
|
||||
views.liveLocationBottomSheetRecyclerView.configureWith(bottomSheetController, hasFixedSize = false, disableItemAnimation = true)
|
||||
|
||||
|
@ -105,11 +117,23 @@ class LiveLocationMapViewFragment :
|
|||
private fun observeViewEvents() {
|
||||
viewModel.observeViewEvents { viewEvent ->
|
||||
when (viewEvent) {
|
||||
is LiveLocationMapViewEvents.Error -> displayErrorDialog(viewEvent.error)
|
||||
is LiveLocationMapViewEvents.LiveLocationError -> displayErrorDialog(viewEvent.error)
|
||||
is LiveLocationMapViewEvents.ZoomToUserLocation -> handleZoomToUserLocationEvent(viewEvent)
|
||||
LiveLocationMapViewEvents.UserLocationNotAvailableError -> handleUserLocationNotAvailableError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleZoomToUserLocationEvent(event: LiveLocationMapViewEvents.ZoomToUserLocation) {
|
||||
mapboxMap?.get().zoomToLocation(event.userLocation)
|
||||
}
|
||||
|
||||
private fun handleUserLocationNotAvailableError() {
|
||||
showUserLocationNotAvailableErrorDialog {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
onSymbolClickListener?.let { symbolManager?.removeClickListener(it) }
|
||||
symbolManager?.onDestroy()
|
||||
|
@ -139,14 +163,33 @@ class LiveLocationMapViewFragment :
|
|||
true
|
||||
}.also { addClickListener(it) }
|
||||
}
|
||||
pendingLiveLocations
|
||||
.takeUnless { it.isEmpty() }
|
||||
?.let { updateMap(it) }
|
||||
// force refresh of the map using the last viewState
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initLocateButton() {
|
||||
views.liveLocationMapLocateButton.setOnClickListener {
|
||||
if (checkPermissions(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, requireActivity(), foregroundLocationResultLauncher)) {
|
||||
zoomToUserLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun zoomToUserLocation() {
|
||||
viewModel.handle(LiveLocationMapAction.ZoomToUserLocation)
|
||||
}
|
||||
|
||||
private val foregroundLocationResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
|
||||
if (allGranted) {
|
||||
zoomToUserLocation()
|
||||
} else if (deniedPermanently) {
|
||||
activity?.onPermissionDeniedDialog(R.string.denied_permission_generic)
|
||||
}
|
||||
}
|
||||
|
||||
private fun listenMapLoadingError(mapView: MapView) {
|
||||
mapLoadingErrorListener = MapView.OnDidFailLoadingMapListener {
|
||||
viewModel.handle(LiveLocationMapAction.ShowMapLoadingError)
|
||||
|
@ -189,9 +232,15 @@ class LiveLocationMapViewFragment :
|
|||
views.mapPreviewLoadingError.isVisible = true
|
||||
} else {
|
||||
views.mapPreviewLoadingError.isGone = true
|
||||
updateMap(viewState.userLocations)
|
||||
updateMap(userLiveLocations = viewState.userLocations, userLocation = viewState.lastKnownUserLocation)
|
||||
}
|
||||
if (viewState.isLoadingUserLocation) {
|
||||
showLoadingDialog()
|
||||
} else {
|
||||
dismissLoadingDialog()
|
||||
}
|
||||
updateUserListBottomSheet(viewState.userLocations)
|
||||
updateLocateButton(showLocateButton = viewState.showLocateUserButton)
|
||||
}
|
||||
|
||||
private fun updateUserListBottomSheet(userLocations: List<UserLiveLocationViewState>) {
|
||||
|
@ -236,7 +285,24 @@ class LiveLocationMapViewFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateMap(userLiveLocations: List<UserLiveLocationViewState>) {
|
||||
private fun updateLocateButton(showLocateButton: Boolean) {
|
||||
views.liveLocationMapLocateButton.isVisible = showLocateButton
|
||||
adjustCompassButton()
|
||||
}
|
||||
|
||||
private fun adjustCompassButton() {
|
||||
val locateButton = views.liveLocationMapLocateButton
|
||||
locateButton.post {
|
||||
val marginTop = locateButton.height + locateButton.marginTop + locateButton.marginBottom
|
||||
val marginRight = locateButton.context.resources.getDimensionPixelOffset(R.dimen.location_sharing_compass_button_margin_horizontal)
|
||||
mapboxMap?.get()?.uiSettings?.setCompassMargins(0, marginTop, marginRight, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMap(
|
||||
userLiveLocations: List<UserLiveLocationViewState>,
|
||||
userLocation: LocationData?,
|
||||
) {
|
||||
symbolManager?.let { sManager ->
|
||||
val latLngBoundsBuilder = LatLngBounds.Builder()
|
||||
userLiveLocations.forEach { userLocation ->
|
||||
|
@ -249,28 +315,60 @@ class LiveLocationMapViewFragment :
|
|||
|
||||
removeOutdatedSymbols(userLiveLocations, sManager)
|
||||
updateMapZoomWhenNeeded(userLiveLocations, latLngBoundsBuilder)
|
||||
} ?: postponeUpdateOfMap(userLiveLocations)
|
||||
}
|
||||
|
||||
private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) = withState(viewModel) { state ->
|
||||
val symbolId = state.mapSymbolIds[userLocation.matrixItem.id]
|
||||
|
||||
if (symbolId == null || symbolManager.annotations.get(symbolId) == null) {
|
||||
createSymbol(userLocation, symbolManager)
|
||||
} else {
|
||||
updateSymbol(symbolId, userLocation, symbolManager)
|
||||
if (userLocation == null) {
|
||||
removeUserSymbol(sManager)
|
||||
} else {
|
||||
createOrUpdateUserSymbol(userLocation, sManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
|
||||
addUserPinToMapStyle(userLocation.matrixItem.id, userLocation.pinDrawable)
|
||||
val symbolOptions = buildSymbolOptions(userLocation)
|
||||
val symbol = symbolManager.create(symbolOptions)
|
||||
viewModel.handle(LiveLocationMapAction.AddMapSymbol(userLocation.matrixItem.id, symbol.id))
|
||||
private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
|
||||
val pinId = userLocation.matrixItem.id
|
||||
val pinDrawable = userLocation.pinDrawable
|
||||
createOrUpdateSymbol(pinId, pinDrawable, userLocation.locationData, symbolManager)
|
||||
}
|
||||
|
||||
private fun updateSymbol(symbolId: Long, userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
|
||||
val newLocation = LatLng(userLocation.locationData.latitude, userLocation.locationData.longitude)
|
||||
private fun createOrUpdateUserSymbol(locationData: LocationData, symbolManager: SymbolManager) {
|
||||
userLocationDrawable?.let { pinDrawable -> createOrUpdateSymbol(USER_LOCATION_PIN_ID, pinDrawable, locationData, symbolManager) }
|
||||
}
|
||||
|
||||
private fun removeUserSymbol(symbolManager: SymbolManager) = withState(viewModel) { state ->
|
||||
val pinId = USER_LOCATION_PIN_ID
|
||||
state.mapSymbolIds[pinId]?.let { symbolId ->
|
||||
removeSymbol(pinId, symbolId, symbolManager)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOrUpdateSymbol(
|
||||
pinId: String,
|
||||
pinDrawable: Drawable,
|
||||
locationData: LocationData,
|
||||
symbolManager: SymbolManager
|
||||
) = withState(viewModel) { state ->
|
||||
val symbolId = state.mapSymbolIds[pinId]
|
||||
|
||||
if (symbolId == null || symbolManager.annotations.get(symbolId) == null) {
|
||||
createSymbol(pinId, pinDrawable, locationData, symbolManager)
|
||||
} else {
|
||||
updateSymbol(symbolId, locationData, symbolManager)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSymbol(
|
||||
pinId: String,
|
||||
pinDrawable: Drawable,
|
||||
locationData: LocationData,
|
||||
symbolManager: SymbolManager
|
||||
) {
|
||||
addPinToMapStyle(pinId, pinDrawable)
|
||||
val symbolOptions = buildSymbolOptions(locationData, pinId)
|
||||
val symbol = symbolManager.create(symbolOptions)
|
||||
viewModel.handle(LiveLocationMapAction.AddMapSymbol(pinId, symbol.id))
|
||||
}
|
||||
|
||||
private fun updateSymbol(symbolId: Long, locationData: LocationData, symbolManager: SymbolManager) {
|
||||
val newLocation = LatLng(locationData.latitude, locationData.longitude)
|
||||
val symbol = symbolManager.annotations.get(symbolId)
|
||||
symbol?.let {
|
||||
it.latLng = newLocation
|
||||
|
@ -279,17 +377,11 @@ class LiveLocationMapViewFragment :
|
|||
}
|
||||
|
||||
private fun removeOutdatedSymbols(userLiveLocations: List<UserLiveLocationViewState>, symbolManager: SymbolManager) = withState(viewModel) { state ->
|
||||
val userIdsToRemove = state.mapSymbolIds.keys.subtract(userLiveLocations.map { it.matrixItem.id }.toSet())
|
||||
userIdsToRemove.forEach { userId ->
|
||||
removeUserPinFromMapStyle(userId)
|
||||
viewModel.handle(LiveLocationMapAction.RemoveMapSymbol(userId))
|
||||
|
||||
state.mapSymbolIds[userId]?.let { symbolId ->
|
||||
Timber.d("trying to delete symbol with id: $symbolId")
|
||||
symbolManager.annotations.get(symbolId)?.let {
|
||||
symbolManager.delete(it)
|
||||
}
|
||||
}
|
||||
val pinIdsToKeep = userLiveLocations.map { it.matrixItem.id } + USER_LOCATION_PIN_ID
|
||||
val pinIdsToRemove = state.mapSymbolIds.keys.subtract(pinIdsToKeep.toSet())
|
||||
pinIdsToRemove.forEach { pinId ->
|
||||
val symbolId = state.mapSymbolIds[pinId]
|
||||
removeSymbol(pinId, symbolId, symbolManager)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -304,27 +396,35 @@ class LiveLocationMapViewFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun postponeUpdateOfMap(userLiveLocations: List<UserLiveLocationViewState>) {
|
||||
pendingLiveLocations.clear()
|
||||
pendingLiveLocations.addAll(userLiveLocations)
|
||||
}
|
||||
|
||||
private fun addUserPinToMapStyle(userId: String, userPinDrawable: Drawable) {
|
||||
private fun addPinToMapStyle(pinId: String, pinDrawable: Drawable) {
|
||||
mapStyle?.let { style ->
|
||||
if (style.getImage(userId) == null) {
|
||||
style.addImage(userId, userPinDrawable.toBitmap())
|
||||
if (style.getImage(pinId) == null) {
|
||||
style.addImage(pinId, pinDrawable.toBitmap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeUserPinFromMapStyle(userId: String) {
|
||||
mapStyle?.removeImage(userId)
|
||||
private fun removeSymbol(pinId: String, symbolId: Long?, symbolManager: SymbolManager) {
|
||||
removeUserPinFromMapStyle(pinId)
|
||||
|
||||
symbolId?.let { id ->
|
||||
Timber.d("trying to delete symbol with id: $id")
|
||||
symbolManager.annotations.get(id)?.let {
|
||||
symbolManager.delete(it)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.handle(LiveLocationMapAction.RemoveMapSymbol(pinId))
|
||||
}
|
||||
|
||||
private fun buildSymbolOptions(userLiveLocation: UserLiveLocationViewState) =
|
||||
private fun removeUserPinFromMapStyle(pinId: String) {
|
||||
mapStyle?.removeImage(pinId)
|
||||
}
|
||||
|
||||
private fun buildSymbolOptions(locationData: LocationData, pinId: String) =
|
||||
SymbolOptions()
|
||||
.withLatLng(LatLng(userLiveLocation.locationData.latitude, userLiveLocation.locationData.longitude))
|
||||
.withIconImage(userLiveLocation.matrixItem.id)
|
||||
.withLatLng(LatLng(locationData.latitude, locationData.longitude))
|
||||
.withIconImage(pinId)
|
||||
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
|
||||
|
||||
private fun handleBottomSheetUserSelected(userId: String) = withState(viewModel) { state ->
|
||||
|
|
|
@ -23,19 +23,27 @@ import dagger.assisted.AssistedInject
|
|||
import im.vector.app.core.di.MavericksAssistedViewModelFactory
|
||||
import im.vector.app.core.di.hiltMavericksViewModelFactory
|
||||
import im.vector.app.core.platform.VectorViewModel
|
||||
import im.vector.app.features.location.LocationData
|
||||
import im.vector.app.features.location.LocationTracker
|
||||
import im.vector.app.features.location.live.StopLiveLocationShareUseCase
|
||||
import im.vector.app.features.location.live.tracking.LocationSharingServiceConnection
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
|
||||
|
||||
class LiveLocationMapViewModel @AssistedInject constructor(
|
||||
@Assisted private val initialState: LiveLocationMapViewState,
|
||||
private val session: Session,
|
||||
getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase,
|
||||
private val locationSharingServiceConnection: LocationSharingServiceConnection,
|
||||
private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase,
|
||||
) : VectorViewModel<LiveLocationMapViewState, LiveLocationMapAction, LiveLocationMapViewEvents>(initialState), LocationSharingServiceConnection.Callback {
|
||||
private val locationTracker: LocationTracker,
|
||||
) :
|
||||
VectorViewModel<LiveLocationMapViewState, LiveLocationMapAction, LiveLocationMapViewEvents>(initialState),
|
||||
LocationSharingServiceConnection.Callback,
|
||||
LocationTracker.Callback {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : MavericksAssistedViewModelFactory<LiveLocationMapViewModel, LiveLocationMapViewState> {
|
||||
|
@ -46,12 +54,37 @@ class LiveLocationMapViewModel @AssistedInject constructor(
|
|||
|
||||
init {
|
||||
getListOfUserLiveLocationUseCase.execute(initialState.roomId)
|
||||
.onEach { setState { copy(userLocations = it) } }
|
||||
.onEach { setState { copy(userLocations = it, showLocateUserButton = it.none { it.matrixItem.id == session.myUserId }) } }
|
||||
.launchIn(viewModelScope)
|
||||
locationSharingServiceConnection.bind(this)
|
||||
initLocationTracking()
|
||||
}
|
||||
|
||||
private fun initLocationTracking() {
|
||||
locationTracker.addCallback(this)
|
||||
locationTracker.locations
|
||||
.onEach(::onLocationUpdate)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun onLocationUpdate(locationData: LocationData) = withState { state ->
|
||||
val zoomToUserLocation = state.isLoadingUserLocation
|
||||
val showLocateButton = state.showLocateUserButton
|
||||
|
||||
setState {
|
||||
copy(
|
||||
lastKnownUserLocation = if (showLocateButton) locationData else null,
|
||||
isLoadingUserLocation = false,
|
||||
)
|
||||
}
|
||||
|
||||
if (zoomToUserLocation) {
|
||||
_viewEvents.post(LiveLocationMapViewEvents.ZoomToUserLocation(locationData))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
locationTracker.removeCallback(this)
|
||||
locationSharingServiceConnection.unbind(this)
|
||||
super.onCleared()
|
||||
}
|
||||
|
@ -62,6 +95,7 @@ class LiveLocationMapViewModel @AssistedInject constructor(
|
|||
is LiveLocationMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action)
|
||||
LiveLocationMapAction.StopSharing -> handleStopSharing()
|
||||
LiveLocationMapAction.ShowMapLoadingError -> handleShowMapLoadingError()
|
||||
LiveLocationMapAction.ZoomToUserLocation -> handleZoomToUserLocation()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -83,7 +117,7 @@ class LiveLocationMapViewModel @AssistedInject constructor(
|
|||
viewModelScope.launch {
|
||||
val result = stopLiveLocationShareUseCase.execute(initialState.roomId)
|
||||
if (result is UpdateLiveLocationShareResult.Failure) {
|
||||
_viewEvents.post(LiveLocationMapViewEvents.Error(result.error))
|
||||
_viewEvents.post(LiveLocationMapViewEvents.LiveLocationError(result.error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -92,6 +126,18 @@ class LiveLocationMapViewModel @AssistedInject constructor(
|
|||
setState { copy(loadingMapHasFailed = true) }
|
||||
}
|
||||
|
||||
private fun handleZoomToUserLocation() = withState { state ->
|
||||
if (!state.isLoadingUserLocation) {
|
||||
setState {
|
||||
copy(isLoadingUserLocation = true)
|
||||
}
|
||||
viewModelScope.launch(session.coroutineDispatchers.main) {
|
||||
locationTracker.start()
|
||||
locationTracker.requestLastKnownLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLocationServiceRunning(roomIds: Set<String>) {
|
||||
// NOOP
|
||||
}
|
||||
|
@ -101,6 +147,10 @@ class LiveLocationMapViewModel @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun onLocationServiceError(error: Throwable) {
|
||||
_viewEvents.post(LiveLocationMapViewEvents.Error(error))
|
||||
_viewEvents.post(LiveLocationMapViewEvents.LiveLocationError(error))
|
||||
}
|
||||
|
||||
override fun onNoLocationProviderAvailable() {
|
||||
_viewEvents.post(LiveLocationMapViewEvents.UserLocationNotAvailableError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,9 @@ data class LiveLocationMapViewState(
|
|||
*/
|
||||
val mapSymbolIds: Map<String, Long> = emptyMap(),
|
||||
val loadingMapHasFailed: Boolean = false,
|
||||
val showLocateUserButton: Boolean = false,
|
||||
val isLoadingUserLocation: Boolean = false,
|
||||
val lastKnownUserLocation: LocationData? = null,
|
||||
) : MavericksState {
|
||||
constructor(liveLocationMapViewArgs: LiveLocationMapViewArgs) : this(
|
||||
roomId = liveLocationMapViewArgs.roomId
|
||||
|
|
|
@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
|
|||
|
||||
sealed class LocationPreviewAction : VectorViewModelAction {
|
||||
object ShowMapLoadingError : LocationPreviewAction()
|
||||
object ZoomToUserLocation : LocationPreviewAction()
|
||||
}
|
||||
|
|
|
@ -31,18 +31,22 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import im.vector.app.R
|
||||
import im.vector.app.core.platform.VectorBaseFragment
|
||||
import im.vector.app.core.platform.VectorMenuProvider
|
||||
import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING
|
||||
import im.vector.app.core.utils.checkPermissions
|
||||
import im.vector.app.core.utils.onPermissionDeniedDialog
|
||||
import im.vector.app.core.utils.openLocation
|
||||
import im.vector.app.core.utils.registerForPermissionsResult
|
||||
import im.vector.app.databinding.FragmentLocationPreviewBinding
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
|
||||
import im.vector.app.features.location.DEFAULT_PIN_ID
|
||||
import im.vector.app.features.location.LocationSharingArgs
|
||||
import im.vector.app.features.location.MapState
|
||||
import im.vector.app.features.location.UrlMapProvider
|
||||
import im.vector.app.features.location.showUserLocationNotAvailableErrorDialog
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
|
||||
/*
|
||||
* TODO Move locationPinProvider to a ViewModel
|
||||
/**
|
||||
* Screen displaying the expanded map of a static location share.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class LocationPreviewFragment :
|
||||
|
@ -50,7 +54,6 @@ class LocationPreviewFragment :
|
|||
VectorMenuProvider {
|
||||
|
||||
@Inject lateinit var urlMapProvider: UrlMapProvider
|
||||
@Inject lateinit var locationPinProvider: LocationPinProvider
|
||||
|
||||
private val args: LocationSharingArgs by args()
|
||||
|
||||
|
@ -76,8 +79,29 @@ class LocationPreviewFragment :
|
|||
|
||||
lifecycleScope.launchWhenCreated {
|
||||
views.mapView.initialize(urlMapProvider.getMapUrl())
|
||||
loadPinDrawable()
|
||||
}
|
||||
|
||||
observeViewEvents()
|
||||
initLocateButton()
|
||||
}
|
||||
|
||||
private fun observeViewEvents() {
|
||||
viewModel.observeViewEvents {
|
||||
when (it) {
|
||||
LocationPreviewViewEvents.UserLocationNotAvailableError -> handleUserLocationNotAvailableError()
|
||||
is LocationPreviewViewEvents.ZoomToUserLocation -> handleZoomToUserLocationEvent(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUserLocationNotAvailableError() {
|
||||
showUserLocationNotAvailableErrorDialog {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleZoomToUserLocationEvent(event: LocationPreviewViewEvents.ZoomToUserLocation) {
|
||||
views.mapView.zoomToLocation(event.userLocation)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
@ -124,6 +148,24 @@ class LocationPreviewFragment :
|
|||
|
||||
override fun invalidate() = withState(viewModel) { state ->
|
||||
views.mapPreviewLoadingError.isVisible = state.loadingMapHasFailed
|
||||
if (state.isLoadingUserLocation) {
|
||||
showLoadingDialog()
|
||||
} else {
|
||||
dismissLoadingDialog()
|
||||
}
|
||||
updateMap(state)
|
||||
}
|
||||
|
||||
private fun updateMap(viewState: LocationPreviewViewState) {
|
||||
views.mapView.render(
|
||||
MapState(
|
||||
zoomOnlyOnce = true,
|
||||
pinLocationData = viewState.pinLocationData,
|
||||
pinId = viewState.pinUserId ?: DEFAULT_PIN_ID,
|
||||
pinDrawable = viewState.pinDrawable,
|
||||
userLocationData = viewState.lastKnownUserLocation,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getMenuRes() = R.menu.menu_location_preview
|
||||
|
@ -143,21 +185,23 @@ class LocationPreviewFragment :
|
|||
openLocation(requireActivity(), location.latitude, location.longitude)
|
||||
}
|
||||
|
||||
private fun loadPinDrawable() {
|
||||
val location = args.initialLocationData ?: return
|
||||
val userId = args.locationOwnerId
|
||||
|
||||
locationPinProvider.create(userId) { pinDrawable ->
|
||||
lifecycleScope.launchWhenResumed {
|
||||
views.mapView.render(
|
||||
MapState(
|
||||
zoomOnlyOnce = true,
|
||||
userLocationData = location,
|
||||
pinId = args.locationOwnerId ?: DEFAULT_PIN_ID,
|
||||
pinDrawable = pinDrawable
|
||||
)
|
||||
)
|
||||
private fun initLocateButton() {
|
||||
views.mapView.locateButton.setOnClickListener {
|
||||
if (checkPermissions(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, requireActivity(), foregroundLocationResultLauncher)) {
|
||||
zoomToUserLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun zoomToUserLocation() {
|
||||
viewModel.handle(LocationPreviewAction.ZoomToUserLocation)
|
||||
}
|
||||
|
||||
private val foregroundLocationResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
|
||||
if (allGranted) {
|
||||
zoomToUserLocation()
|
||||
} else if (deniedPermanently) {
|
||||
activity?.onPermissionDeniedDialog(R.string.denied_permission_generic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (c) 2021 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.preview
|
||||
|
||||
import im.vector.app.core.platform.VectorViewEvents
|
||||
import im.vector.app.features.location.LocationData
|
||||
|
||||
sealed class LocationPreviewViewEvents : VectorViewEvents {
|
||||
data class ZoomToUserLocation(val userLocation: LocationData) : LocationPreviewViewEvents()
|
||||
object UserLocationNotAvailableError : LocationPreviewViewEvents()
|
||||
}
|
|
@ -22,12 +22,21 @@ import dagger.assisted.AssistedFactory
|
|||
import dagger.assisted.AssistedInject
|
||||
import im.vector.app.core.di.MavericksAssistedViewModelFactory
|
||||
import im.vector.app.core.di.hiltMavericksViewModelFactory
|
||||
import im.vector.app.core.platform.EmptyViewEvents
|
||||
import im.vector.app.core.platform.VectorViewModel
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
|
||||
import im.vector.app.features.location.LocationData
|
||||
import im.vector.app.features.location.LocationTracker
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
|
||||
class LocationPreviewViewModel @AssistedInject constructor(
|
||||
@Assisted private val initialState: LocationPreviewViewState,
|
||||
) : VectorViewModel<LocationPreviewViewState, LocationPreviewAction, EmptyViewEvents>(initialState) {
|
||||
private val session: Session,
|
||||
private val locationPinProvider: LocationPinProvider,
|
||||
private val locationTracker: LocationTracker,
|
||||
) : VectorViewModel<LocationPreviewViewState, LocationPreviewAction, LocationPreviewViewEvents>(initialState), LocationTracker.Callback {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : MavericksAssistedViewModelFactory<LocationPreviewViewModel, LocationPreviewViewState> {
|
||||
|
@ -36,13 +45,68 @@ class LocationPreviewViewModel @AssistedInject constructor(
|
|||
|
||||
companion object : MavericksViewModelFactory<LocationPreviewViewModel, LocationPreviewViewState> by hiltMavericksViewModelFactory()
|
||||
|
||||
init {
|
||||
initPin(initialState.pinUserId)
|
||||
initLocationTracking()
|
||||
}
|
||||
|
||||
private fun initPin(userId: String?) {
|
||||
locationPinProvider.create(userId) { pinDrawable ->
|
||||
setState { copy(pinDrawable = pinDrawable) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun initLocationTracking() {
|
||||
locationTracker.addCallback(this)
|
||||
locationTracker.locations
|
||||
.onEach(::onLocationUpdate)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
locationTracker.removeCallback(this)
|
||||
}
|
||||
|
||||
override fun handle(action: LocationPreviewAction) {
|
||||
when (action) {
|
||||
LocationPreviewAction.ShowMapLoadingError -> handleShowMapLoadingError()
|
||||
LocationPreviewAction.ZoomToUserLocation -> handleZoomToUserLocationAction()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShowMapLoadingError() {
|
||||
setState { copy(loadingMapHasFailed = true) }
|
||||
}
|
||||
|
||||
private fun handleZoomToUserLocationAction() = withState { state ->
|
||||
if (!state.isLoadingUserLocation) {
|
||||
setState {
|
||||
copy(isLoadingUserLocation = true)
|
||||
}
|
||||
viewModelScope.launch(session.coroutineDispatchers.main) {
|
||||
locationTracker.start()
|
||||
locationTracker.requestLastKnownLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNoLocationProviderAvailable() {
|
||||
_viewEvents.post(LocationPreviewViewEvents.UserLocationNotAvailableError)
|
||||
}
|
||||
|
||||
private fun onLocationUpdate(locationData: LocationData) = withState { state ->
|
||||
val zoomToUserLocation = state.isLoadingUserLocation
|
||||
|
||||
setState {
|
||||
copy(
|
||||
lastKnownUserLocation = locationData,
|
||||
isLoadingUserLocation = false,
|
||||
)
|
||||
}
|
||||
|
||||
if (zoomToUserLocation) {
|
||||
_viewEvents.post(LocationPreviewViewEvents.ZoomToUserLocation(locationData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,8 +16,22 @@
|
|||
|
||||
package im.vector.app.features.location.preview
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.airbnb.mvrx.MavericksState
|
||||
import im.vector.app.features.location.LocationData
|
||||
import im.vector.app.features.location.LocationSharingArgs
|
||||
|
||||
data class LocationPreviewViewState(
|
||||
val loadingMapHasFailed: Boolean = false
|
||||
) : MavericksState
|
||||
val pinLocationData: LocationData? = null,
|
||||
val pinUserId: String? = null,
|
||||
val pinDrawable: Drawable? = null,
|
||||
val loadingMapHasFailed: Boolean = false,
|
||||
val isLoadingUserLocation: Boolean = false,
|
||||
val lastKnownUserLocation: LocationData? = null,
|
||||
) : MavericksState {
|
||||
|
||||
constructor(args: LocationSharingArgs) : this(
|
||||
pinLocationData = args.initialLocationData,
|
||||
pinUserId = args.locationOwnerId,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,14 +1,9 @@
|
|||
<?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>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="28dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="28">
|
||||
<path
|
||||
android:pathData="M10,0.667C4.84,0.667 0.667,4.953 0.667,10.254C0.667,15.965 6.56,23.841 8.987,26.84C9.52,27.497 10.493,27.497 11.027,26.84C13.44,23.841 19.333,15.965 19.333,10.254C19.333,4.953 15.16,0.667 10,0.667ZM10,13.678C8.16,13.678 6.667,12.144 6.667,10.254C6.667,8.364 8.16,6.83 10,6.83C11.84,6.83 13.333,8.364 13.333,10.254C13.333,12.144 11.84,13.678 10,13.678Z"
|
||||
android:fillColor="#0DBD8B"/>
|
||||
</vector>
|
||||
|
|
|
@ -17,6 +17,20 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/liveLocationMapLocateButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_marginHorizontal="@dimen/location_sharing_locate_button_margin_horizontal"
|
||||
android:layout_marginVertical="@dimen/location_sharing_locate_button_margin_vertical"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/a11y_location_share_locate_button"
|
||||
android:focusable="true"
|
||||
android:src="@drawable/btn_locate"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<im.vector.app.features.location.MapLoadingErrorView
|
||||
android:id="@+id/mapPreviewLoadingError"
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:mapbox_renderTextureMode="true"
|
||||
app:showLocateButton="false" />
|
||||
app:showLocateButton="true" />
|
||||
|
||||
<im.vector.app.features.location.MapLoadingErrorView
|
||||
android:id="@+id/mapPreviewLoadingError"
|
||||
|
|
|
@ -20,13 +20,14 @@ import com.airbnb.mvrx.test.MavericksTestRule
|
|||
import im.vector.app.features.location.LocationData
|
||||
import im.vector.app.features.location.live.StopLiveLocationShareUseCase
|
||||
import im.vector.app.test.fakes.FakeLocationSharingServiceConnection
|
||||
import im.vector.app.test.fakes.FakeLocationTracker
|
||||
import im.vector.app.test.fakes.FakeSession
|
||||
import im.vector.app.test.test
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
@ -41,16 +42,20 @@ class LiveLocationMapViewModelTest {
|
|||
|
||||
private val args = LiveLocationMapViewArgs(roomId = A_ROOM_ID)
|
||||
|
||||
private val getListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>()
|
||||
private val locationServiceConnection = FakeLocationSharingServiceConnection()
|
||||
private val stopLiveLocationShareUseCase = mockk<StopLiveLocationShareUseCase>()
|
||||
private val fakeSession = FakeSession()
|
||||
private val fakeGetListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>()
|
||||
private val fakeLocationSharingServiceConnection = FakeLocationSharingServiceConnection()
|
||||
private val fakeStopLiveLocationShareUseCase = mockk<StopLiveLocationShareUseCase>()
|
||||
private val fakeLocationTracker = FakeLocationTracker()
|
||||
|
||||
private fun createViewModel(): LiveLocationMapViewModel {
|
||||
return LiveLocationMapViewModel(
|
||||
LiveLocationMapViewState(args),
|
||||
getListOfUserLiveLocationUseCase,
|
||||
locationServiceConnection.instance,
|
||||
stopLiveLocationShareUseCase
|
||||
session = fakeSession,
|
||||
getListOfUserLiveLocationUseCase = fakeGetListOfUserLiveLocationUseCase,
|
||||
locationSharingServiceConnection = fakeLocationSharingServiceConnection.instance,
|
||||
stopLiveLocationShareUseCase = fakeStopLiveLocationShareUseCase,
|
||||
locationTracker = fakeLocationTracker.instance,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -60,30 +65,94 @@ class LiveLocationMapViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `given the viewModel has been initialized then viewState contains user locations list`() = runTest {
|
||||
val userLocations = listOf(
|
||||
UserLiveLocationViewState(
|
||||
MatrixItem.UserItem(id = "@userId1:matrix.org", displayName = "User 1", avatarUrl = ""),
|
||||
pinDrawable = mockk(),
|
||||
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
|
||||
endOfLiveTimestampMillis = 123,
|
||||
locationTimestampMillis = 123,
|
||||
showStopSharingButton = false
|
||||
)
|
||||
)
|
||||
locationServiceConnection.givenBind()
|
||||
every { getListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
|
||||
fun `given the viewModel has been initialized then viewState contains user locations list and location tracker is setup`() {
|
||||
// Given
|
||||
val userLocations = listOf(givenAUserLiveLocationViewState(userId = "@userId1:matrix.org"))
|
||||
fakeLocationSharingServiceConnection.givenBind()
|
||||
every { fakeGetListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
|
||||
|
||||
// When
|
||||
val viewModel = createViewModel()
|
||||
viewModel
|
||||
.test()
|
||||
val viewModelTest = viewModel.test()
|
||||
|
||||
// Then
|
||||
viewModelTest
|
||||
.assertState(
|
||||
LiveLocationMapViewState(args).copy(
|
||||
userLocations = userLocations
|
||||
userLocations = userLocations,
|
||||
showLocateUserButton = true,
|
||||
)
|
||||
).finish()
|
||||
fakeLocationSharingServiceConnection.verifyBind(viewModel)
|
||||
fakeLocationTracker.verifyAddCallback(viewModel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given the viewModel when it is cleared cleanUp are done`() {
|
||||
// Given
|
||||
fakeLocationSharingServiceConnection.givenBind()
|
||||
fakeLocationSharingServiceConnection.givenUnbind()
|
||||
every { fakeGetListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(emptyList())
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// When
|
||||
viewModel.onCleared()
|
||||
|
||||
// Then
|
||||
fakeLocationSharingServiceConnection.verifyUnbind(viewModel)
|
||||
fakeLocationTracker.verifyRemoveCallback(viewModel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given current user shares their live location then locate button should not be shown`() {
|
||||
// Given
|
||||
val userLocations = listOf(givenAUserLiveLocationViewState(userId = fakeSession.myUserId))
|
||||
fakeLocationSharingServiceConnection.givenBind()
|
||||
every { fakeGetListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// When
|
||||
val viewModelTest = viewModel.test()
|
||||
|
||||
// Then
|
||||
viewModelTest
|
||||
.assertState(
|
||||
LiveLocationMapViewState(args).copy(
|
||||
userLocations = userLocations,
|
||||
showLocateUserButton = false,
|
||||
)
|
||||
)
|
||||
.finish()
|
||||
|
||||
locationServiceConnection.verifyBind(viewModel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given current user does not share their live location then locate button should be shown`() {
|
||||
// Given
|
||||
val userLocations = listOf(givenAUserLiveLocationViewState(userId = "@userId1:matrix.org"))
|
||||
fakeLocationSharingServiceConnection.givenBind()
|
||||
every { fakeGetListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// When
|
||||
val viewModelTest = viewModel.test()
|
||||
|
||||
// Then
|
||||
viewModelTest
|
||||
.assertState(
|
||||
LiveLocationMapViewState(args).copy(
|
||||
userLocations = userLocations,
|
||||
showLocateUserButton = true,
|
||||
)
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
private fun givenAUserLiveLocationViewState(userId: String) = UserLiveLocationViewState(
|
||||
MatrixItem.UserItem(id = userId, displayName = "User 1", avatarUrl = ""),
|
||||
pinDrawable = mockk(),
|
||||
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
|
||||
endOfLiveTimestampMillis = 123,
|
||||
locationTimestampMillis = 123,
|
||||
showStopSharingButton = false
|
||||
)
|
||||
}
|
||||
|
|
|
@ -17,10 +17,8 @@
|
|||
package im.vector.app.test.fakes
|
||||
|
||||
import im.vector.app.features.location.live.tracking.LocationSharingServiceConnection
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.justRun
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
|
||||
class FakeLocationSharingServiceConnection {
|
||||
|
@ -28,10 +26,18 @@ class FakeLocationSharingServiceConnection {
|
|||
val instance = mockk<LocationSharingServiceConnection>()
|
||||
|
||||
fun givenBind() {
|
||||
every { instance.bind(any()) } just runs
|
||||
justRun { instance.bind(any()) }
|
||||
}
|
||||
|
||||
fun verifyBind(callback: LocationSharingServiceConnection.Callback) {
|
||||
verify { instance.bind(callback) }
|
||||
}
|
||||
|
||||
fun givenUnbind() {
|
||||
justRun { instance.unbind(any()) }
|
||||
}
|
||||
|
||||
fun verifyUnbind(callback: LocationSharingServiceConnection.Callback) {
|
||||
verify { instance.unbind(callback) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.app.test.fakes
|
||||
|
||||
import im.vector.app.features.location.LocationTracker
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
|
||||
class FakeLocationTracker {
|
||||
|
||||
val instance: LocationTracker = mockk(relaxed = true)
|
||||
|
||||
fun verifyAddCallback(callback: LocationTracker.Callback) {
|
||||
verify { instance.addCallback(callback) }
|
||||
}
|
||||
|
||||
fun verifyRemoveCallback(callback: LocationTracker.Callback) {
|
||||
verify { instance.removeCallback(callback) }
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue