Render user location pin on the live location sharing map preview

This commit is contained in:
Maxime NATUREL 2023-02-17 14:57:06 +01:00
parent f676a65544
commit b5af6f5a0f
8 changed files with 202 additions and 62 deletions

View file

@ -33,6 +33,7 @@ class CountUpTimer(
private val lastTime: AtomicLong = AtomicLong(clock.epochMillis())
private val elapsedTime: AtomicLong = AtomicLong(0)
// To ensure that the regular tick value is an exact multiple of `intervalInMs`
private val specialRound = SpecialRound(intervalInMs)

View file

@ -23,4 +23,5 @@ sealed class LiveLocationMapAction : VectorViewModelAction {
data class RemoveMapSymbol(val key: String) : LiveLocationMapAction()
object StopSharing : LiveLocationMapAction()
object ShowMapLoadingError : LiveLocationMapAction()
object ZoomToUserLocation : LiveLocationMapAction()
}

View file

@ -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
}

View file

@ -48,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
@ -60,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.
*/
@ -70,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()
@ -77,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
@ -90,6 +99,7 @@ class LiveLocationMapViewFragment :
super.onViewCreated(view, savedInstanceState)
observeViewEvents()
setupMap()
initLocateButton()
views.liveLocationBottomSheetRecyclerView.configureWith(bottomSheetController, hasFixedSize = false, disableItemAnimation = true)
@ -107,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()
@ -141,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)
@ -191,10 +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.showLocateButton)
updateLocateButton(showLocateButton = viewState.showLocateUserButton)
}
private fun updateUserListBottomSheet(userLocations: List<UserLiveLocationViewState>) {
@ -253,7 +299,10 @@ class LiveLocationMapViewFragment :
}
}
private fun updateMap(userLiveLocations: List<UserLiveLocationViewState>) {
private fun updateMap(
userLiveLocations: List<UserLiveLocationViewState>,
userLocation: LocationData?,
) {
symbolManager?.let { sManager ->
val latLngBoundsBuilder = LatLngBounds.Builder()
userLiveLocations.forEach { userLocation ->
@ -266,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
@ -296,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)
}
}
@ -321,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 ->

View file

@ -23,8 +23,11 @@ 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.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -37,7 +40,11 @@ class LiveLocationMapViewModel @AssistedInject constructor(
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> {
@ -48,12 +55,37 @@ class LiveLocationMapViewModel @AssistedInject constructor(
init {
getListOfUserLiveLocationUseCase.execute(initialState.roomId)
.onEach { setState { copy(userLocations = it, showLocateButton = it.none { it.matrixItem.id == session.myUserId }) } }
.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()
}
@ -64,6 +96,7 @@ class LiveLocationMapViewModel @AssistedInject constructor(
is LiveLocationMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action)
LiveLocationMapAction.StopSharing -> handleStopSharing()
LiveLocationMapAction.ShowMapLoadingError -> handleShowMapLoadingError()
LiveLocationMapAction.ZoomToUserLocation -> handleZoomToUserLocation()
}
}
@ -85,7 +118,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))
}
}
}
@ -94,6 +127,19 @@ class LiveLocationMapViewModel @AssistedInject constructor(
setState { copy(loadingMapHasFailed = true) }
}
// TODO add unit tests
private fun handleZoomToUserLocation() = withState { state ->
if (!state.isLoadingUserLocation) {
setState {
copy(isLoadingUserLocation = true)
}
viewModelScope.launch(Dispatchers.Main) {
locationTracker.start()
locationTracker.requestLastKnownLocation()
}
}
}
override fun onLocationServiceRunning(roomIds: Set<String>) {
// NOOP
}
@ -103,6 +149,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)
}
}

View file

@ -29,7 +29,9 @@ data class LiveLocationMapViewState(
*/
val mapSymbolIds: Map<String, Long> = emptyMap(),
val loadingMapHasFailed: Boolean = false,
val showLocateButton: Boolean = false,
val showLocateUserButton: Boolean = false,
val isLoadingUserLocation: Boolean = false,
val lastKnownUserLocation: LocationData? = null,
) : MavericksState {
constructor(liveLocationMapViewArgs: LiveLocationMapViewArgs) : this(
roomId = liveLocationMapViewArgs.roomId

View file

@ -94,12 +94,8 @@ class LocationPreviewViewModel @AssistedInject constructor(
_viewEvents.post(LocationPreviewViewEvents.UserLocationNotAvailableError)
}
private fun onLocationUpdate(locationData: LocationData) {
withState { state ->
if (state.isLoadingUserLocation) {
_viewEvents.post(LocationPreviewViewEvents.ZoomToUserLocation(locationData))
}
}
private fun onLocationUpdate(locationData: LocationData) = withState { state ->
val zoomToUserLocation = state.isLoadingUserLocation
setState {
copy(
@ -107,5 +103,9 @@ class LocationPreviewViewModel @AssistedInject constructor(
isLoadingUserLocation = false,
)
}
if (zoomToUserLocation) {
_viewEvents.post(LocationPreviewViewEvents.ZoomToUserLocation(locationData))
}
}
}

View file

@ -30,7 +30,7 @@ data class LocationPreviewViewState(
val lastKnownUserLocation: LocationData? = null,
) : MavericksState {
constructor(args: LocationSharingArgs): this(
constructor(args: LocationSharingArgs) : this(
pinLocationData = args.initialLocationData,
pinUserId = args.locationOwnerId,
)