Merge pull request #6170 from vector-im/feature/ons/live_location_bottom_sheet

Live Location Sharing - User List Bottom Sheet [PSF-890]
This commit is contained in:
Onuray Sahin 2022-05-30 21:07:25 +03:00 committed by GitHub
commit 4ccd242cbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 558 additions and 58 deletions

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

@ -0,0 +1 @@
Live Location Sharing - User List Bottom Sheet

View File

@ -18,4 +18,26 @@
<item name="android:gravity">center</item> <item name="android:gravity">center</item>
</style> </style>
<style name="TextAppearance.Vector.Body.BottomSheetDisplayName">
<item name="android:textSize">16sp</item>
</style>
<style name="TextAppearance.Vector.Body.BottomSheetRemainingTime">
<item name="android:textSize">12sp</item>
</style>
<style name="TextAppearance.Vector.Body.BottomSheetLastUpdatedAt">
<item name="android:textSize">12sp</item>
<item name="android:textColor">?vctr_content_tertiary</item>
</style>
<style name="Widget.Vector.Button.Text.BottomSheetStopSharing">
<item name="android:foreground">?selectableItemBackground</item>
<item name="android:background">@android:color/transparent</item>
<item name="android:textAppearance">@style/TextAppearance.Vector.Body.Medium</item>
<item name="android:textColor">?colorError</item>
<item name="android:padding">0dp</item>
<item name="android:gravity">center</item>
</style>
</resources> </resources>

View File

@ -0,0 +1,14 @@
{
"data": [
{
"displayName": "Amandine",
"remainingTime": "9min left",
"lastUpdatedAt": "Updated 12min ago"
},
{
"displayName": "You",
"remainingTime": "19min left",
"lastUpdatedAt": "Updated 1min ago"
}
]
}

View File

@ -20,6 +20,7 @@ import android.content.Context
import android.os.Build import android.os.Build
import android.text.format.Formatter import android.text.format.Formatter
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import org.threeten.bp.Duration import org.threeten.bp.Duration
import java.util.TreeMap import java.util.TreeMap
@ -85,50 +86,66 @@ object TextUtils {
} }
} }
fun formatDurationWithUnits(context: Context, duration: Duration): String { fun formatDurationWithUnits(context: Context, duration: Duration, appendSeconds: Boolean = true): String {
return formatDurationWithUnits(duration, context::getString, appendSeconds)
}
fun formatDurationWithUnits(stringProvider: StringProvider, duration: Duration, appendSeconds: Boolean = true): String {
return formatDurationWithUnits(duration, stringProvider::getString, appendSeconds)
}
/**
* We don't always have Context to get strings or we want to use StringProvider instead.
* So we can pass the getString function either from Context or the StringProvider.
* @param duration duration to be formatted
* @param getString getString method from Context or StringProvider
* @param appendSeconds if false than formatter will not append seconds
* @return formatted duration with a localized form like "10h 30min 5sec"
*/
private fun formatDurationWithUnits(duration: Duration, getString: ((Int) -> String), appendSeconds: Boolean = true): String {
val hours = getHours(duration) val hours = getHours(duration)
val minutes = getMinutes(duration) val minutes = getMinutes(duration)
val seconds = getSeconds(duration) val seconds = getSeconds(duration)
val builder = StringBuilder() val builder = StringBuilder()
when { when {
hours > 0 -> { hours > 0 -> {
appendHours(context, builder, hours) appendHours(getString, builder, hours)
if (minutes > 0) { if (minutes > 0) {
builder.append(" ") builder.append(" ")
appendMinutes(context, builder, minutes) appendMinutes(getString, builder, minutes)
} }
if (seconds > 0) { if (appendSeconds && seconds > 0) {
builder.append(" ") builder.append(" ")
appendSeconds(context, builder, seconds) appendSeconds(getString, builder, seconds)
} }
} }
minutes > 0 -> { minutes > 0 -> {
appendMinutes(context, builder, minutes) appendMinutes(getString, builder, minutes)
if (seconds > 0) { if (appendSeconds && seconds > 0) {
builder.append(" ") builder.append(" ")
appendSeconds(context, builder, seconds) appendSeconds(getString, builder, seconds)
} }
} }
else -> { else -> {
appendSeconds(context, builder, seconds) appendSeconds(getString, builder, seconds)
} }
} }
return builder.toString() return builder.toString()
} }
private fun appendHours(context: Context, builder: StringBuilder, hours: Int) { private fun appendHours(getString: ((Int) -> String), builder: StringBuilder, hours: Int) {
builder.append(hours) builder.append(hours)
builder.append(context.resources.getString(R.string.time_unit_hour_short)) builder.append(getString(R.string.time_unit_hour_short))
} }
private fun appendMinutes(context: Context, builder: StringBuilder, minutes: Int) { private fun appendMinutes(getString: ((Int) -> String), builder: StringBuilder, minutes: Int) {
builder.append(minutes) builder.append(minutes)
builder.append(context.getString(R.string.time_unit_minute_short)) builder.append(getString(R.string.time_unit_minute_short))
} }
private fun appendSeconds(context: Context, builder: StringBuilder, seconds: Int) { private fun appendSeconds(getString: ((Int) -> String), builder: StringBuilder, seconds: Int) {
builder.append(seconds) builder.append(seconds)
builder.append(context.getString(R.string.time_unit_second_short)) builder.append(getString(R.string.time_unit_second_short))
} }
private fun getHours(duration: Duration): Int = duration.toHours().toInt() private fun getHours(duration: Duration): Int = duration.toHours().toInt()

View File

@ -22,10 +22,15 @@ import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.geometry.LatLngBounds import com.mapbox.mapboxsdk.geometry.LatLngBounds
import com.mapbox.mapboxsdk.maps.MapboxMap import com.mapbox.mapboxsdk.maps.MapboxMap
fun MapboxMap?.zoomToLocation(locationData: LocationData) { fun MapboxMap?.zoomToLocation(locationData: LocationData, preserveCurrentZoomLevel: Boolean = false) {
val zoomLevel = if (preserveCurrentZoomLevel && this?.cameraPosition != null) {
cameraPosition.zoom
} else {
INITIAL_MAP_ZOOM_IN_PREVIEW
}
this?.cameraPosition = CameraPosition.Builder() this?.cameraPosition = CameraPosition.Builder()
.target(LatLng(locationData.latitude, locationData.longitude)) .target(LatLng(locationData.latitude, locationData.longitude))
.zoom(INITIAL_MAP_ZOOM_IN_PREVIEW) .zoom(zoomLevel)
.build() .build()
} }

View File

@ -0,0 +1,89 @@
/*
* 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.live.map
import android.content.Context
import com.airbnb.epoxy.EpoxyController
import im.vector.app.R
import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.resources.DateProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.resources.toTimestamp
import im.vector.app.core.time.Clock
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.location.live.map.bottomsheet.LiveLocationUserItem
import im.vector.app.features.location.live.map.bottomsheet.liveLocationUserItem
import javax.inject.Inject
class LiveLocationBottomSheetController @Inject constructor(
private val avatarRenderer: AvatarRenderer,
private val vectorDateFormatter: VectorDateFormatter,
private val stringProvider: StringProvider,
private val clock: Clock,
private val context: Context,
) : EpoxyController() {
interface Callback {
fun onUserSelected(userId: String)
fun onStopLocationClicked()
}
private var userLocations: List<UserLiveLocationViewState>? = null
var callback: Callback? = null
fun setData(userLocations: List<UserLiveLocationViewState>) {
this.userLocations = userLocations
requestModelBuild()
}
override fun buildModels() {
val currentUserLocations = userLocations ?: return
val host = this
val userItemCallback = object : LiveLocationUserItem.Callback {
override fun onUserSelected(userId: String) {
host.callback?.onUserSelected(userId)
}
override fun onStopSharingClicked() {
host.callback?.onStopLocationClicked()
}
}
currentUserLocations.forEach { liveLocationViewState ->
val remainingTime = getFormattedLocalTimeEndOfLive(liveLocationViewState.endOfLiveTimestampMillis)
liveLocationUserItem {
id(liveLocationViewState.matrixItem.id)
callback(userItemCallback)
matrixItem(liveLocationViewState.matrixItem)
stringProvider(host.stringProvider)
clock(host.clock)
avatarRenderer(host.avatarRenderer)
remainingTime(remainingTime)
locationUpdateTimeMillis(liveLocationViewState.locationTimestampMillis)
showStopSharingButton(liveLocationViewState.showStopSharingButton)
}
}
}
private fun getFormattedLocalTimeEndOfLive(endOfLiveDateTimestampMillis: Long?): String {
val endOfLiveDateTime = DateProvider.toLocalDateTime(endOfLiveDateTimestampMillis)
val formattedDateTime = endOfLiveDateTime.toTimestamp().let { vectorDateFormatter.format(it, DateFormatKind.MESSAGE_SIMPLE) }
return stringProvider.getString(R.string.location_share_live_until, formattedDateTime)
}
}

View File

@ -0,0 +1,121 @@
/*
* 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.live.map.bottomsheet
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.time.Clock
import im.vector.app.core.utils.TextUtils
import im.vector.app.features.home.AvatarRenderer
import im.vector.lib.core.utils.timer.CountUpTimer
import org.matrix.android.sdk.api.util.MatrixItem
import org.threeten.bp.Duration
@EpoxyModelClass(layout = R.layout.item_live_location_users_bottom_sheet)
abstract class LiveLocationUserItem : VectorEpoxyModel<LiveLocationUserItem.Holder>() {
interface Callback {
fun onUserSelected(userId: String)
fun onStopSharingClicked()
}
@EpoxyAttribute
var callback: Callback? = null
@EpoxyAttribute
lateinit var matrixItem: MatrixItem
@EpoxyAttribute
lateinit var avatarRenderer: AvatarRenderer
@EpoxyAttribute
lateinit var stringProvider: StringProvider
@EpoxyAttribute
lateinit var clock: Clock
@EpoxyAttribute
var remainingTime: String? = null
@EpoxyAttribute
var locationUpdateTimeMillis: Long? = null
@EpoxyAttribute
var showStopSharingButton: Boolean = false
override fun bind(holder: Holder) {
super.bind(holder)
avatarRenderer.render(matrixItem, holder.itemUserAvatarImageView)
holder.itemUserDisplayNameTextView.text = matrixItem.displayName
holder.itemRemainingTimeTextView.text = remainingTime
holder.itemStopSharingButton.isVisible = showStopSharingButton
if (showStopSharingButton) {
holder.itemStopSharingButton.onClick {
callback?.onStopSharingClicked()
}
}
stopTimer(holder)
holder.timer = CountUpTimer(1000).apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
holder.itemLastUpdatedAtTextView.text = getFormattedLastUpdatedAt(locationUpdateTimeMillis)
}
}
resume()
}
holder.view.setOnClickListener { callback?.onUserSelected(matrixItem.id) }
}
override fun unbind(holder: Holder) {
super.unbind(holder)
stopTimer(holder)
}
private fun stopTimer(holder: Holder) {
holder.timer?.stop()
holder.timer = null
}
private fun getFormattedLastUpdatedAt(locationUpdateTimeMillis: Long?): String {
if (locationUpdateTimeMillis == null) return ""
val elapsedTime = clock.epochMillis() - locationUpdateTimeMillis
val duration = Duration.ofMillis(elapsedTime.coerceAtLeast(0L))
val formattedDuration = TextUtils.formatDurationWithUnits(stringProvider, duration, appendSeconds = false)
return stringProvider.getString(R.string.live_location_bottom_sheet_last_updated_at, formattedDuration)
}
class Holder : VectorEpoxyHolder() {
var timer: CountUpTimer? = null
val itemUserAvatarImageView by bind<ImageView>(R.id.itemUserAvatarImageView)
val itemUserDisplayNameTextView by bind<TextView>(R.id.itemUserDisplayNameTextView)
val itemRemainingTimeTextView by bind<TextView>(R.id.itemRemainingTimeTextView)
val itemLastUpdatedAtTextView by bind<TextView>(R.id.itemLastUpdatedAtTextView)
val itemStopSharingButton by bind<Button>(R.id.itemStopSharingButton)
}
}

View File

@ -21,4 +21,5 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class LocationLiveMapAction : VectorViewModelAction { sealed class LocationLiveMapAction : VectorViewModelAction {
data class AddMapSymbol(val key: String, val value: Long) : LocationLiveMapAction() data class AddMapSymbol(val key: String, val value: Long) : LocationLiveMapAction()
data class RemoveMapSymbol(val key: String) : LocationLiveMapAction() data class RemoveMapSymbol(val key: String) : LocationLiveMapAction()
object StopSharing : LocationLiveMapAction()
} }

View File

@ -17,7 +17,9 @@
package im.vector.app.features.location.live.map package im.vector.app.features.location.live.map
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -36,8 +38,9 @@ import com.mapbox.mapboxsdk.style.layers.Property
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.addChildFragment import im.vector.app.core.extensions.addChildFragment
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentSimpleContainerBinding import im.vector.app.databinding.FragmentLocationLiveMapViewBinding
import im.vector.app.features.location.UrlMapProvider import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.zoomToBounds import im.vector.app.features.location.zoomToBounds
import im.vector.app.features.location.zoomToLocation import im.vector.app.features.location.zoomToLocation
@ -49,10 +52,12 @@ import javax.inject.Inject
/** /**
* Screen showing a map with all the current users sharing their live location in a room. * Screen showing a map with all the current users sharing their live location in a room.
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<FragmentSimpleContainerBinding>() { class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<FragmentLocationLiveMapViewBinding>() {
@Inject lateinit var urlMapProvider: UrlMapProvider @Inject lateinit var urlMapProvider: UrlMapProvider
@Inject lateinit var bottomSheetController: LiveLocationBottomSheetController
private val viewModel: LocationLiveMapViewModel by fragmentViewModel() private val viewModel: LocationLiveMapViewModel by fragmentViewModel()
@ -62,8 +67,23 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
private val pendingLiveLocations = mutableListOf<UserLiveLocationViewState>() private val pendingLiveLocations = mutableListOf<UserLiveLocationViewState>()
private var isMapFirstUpdate = true private var isMapFirstUpdate = true
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSimpleContainerBinding { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationLiveMapViewBinding {
return FragmentSimpleContainerBinding.inflate(layoutInflater, container, false) return FragmentLocationLiveMapViewBinding.inflate(layoutInflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.liveLocationBottomSheetRecyclerView.configureWith(bottomSheetController, hasFixedSize = false, disableItemAnimation = true)
bottomSheetController.callback = object : LiveLocationBottomSheetController.Callback {
override fun onUserSelected(userId: String) {
handleBottomSheetUserSelected(userId)
}
override fun onStopLocationClicked() {
viewModel.handle(LocationLiveMapAction.StopSharing)
}
}
} }
override fun onResume() { override fun onResume() {
@ -78,7 +98,9 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
mapboxMap.setStyle(urlMapProvider.getMapUrl()) { style -> mapboxMap.setStyle(urlMapProvider.getMapUrl()) { style ->
mapStyle = style mapStyle = style
this@LocationLiveMapViewFragment.mapboxMap = WeakReference(mapboxMap) this@LocationLiveMapViewFragment.mapboxMap = WeakReference(mapboxMap)
symbolManager = SymbolManager(mapFragment.view as MapView, mapboxMap, style) symbolManager = SymbolManager(mapFragment.view as MapView, mapboxMap, style).apply {
iconAllowOverlap = true
}
pendingLiveLocations pendingLiveLocations
.takeUnless { it.isEmpty() } .takeUnless { it.isEmpty() }
?.let { updateMap(it) } ?.let { updateMap(it) }
@ -92,11 +114,16 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
?: run { ?: run {
val options = MapboxMapOptions.createFromAttributes(requireContext(), null) val options = MapboxMapOptions.createFromAttributes(requireContext(), null)
SupportMapFragment.newInstance(options) SupportMapFragment.newInstance(options)
.also { addChildFragment(R.id.fragmentContainer, it, tag = MAP_FRAGMENT_TAG) } .also { addChildFragment(R.id.liveLocationMapFragmentContainer, it, tag = MAP_FRAGMENT_TAG) }
} }
override fun invalidate() = withState(viewModel) { viewState -> override fun invalidate() = withState(viewModel) { viewState ->
updateMap(viewState.userLocations) updateMap(viewState.userLocations)
updateUserListBottomSheet(viewState.userLocations)
}
private fun updateUserListBottomSheet(userLocations: List<UserLiveLocationViewState>) {
bottomSheetController.setData(userLocations)
} }
private fun updateMap(userLiveLocations: List<UserLiveLocationViewState>) { private fun updateMap(userLiveLocations: List<UserLiveLocationViewState>) {
@ -116,7 +143,7 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
} }
private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) = withState(viewModel) { state -> private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) = withState(viewModel) { state ->
val symbolId = state.mapSymbolIds[userLocation.userId] val symbolId = state.mapSymbolIds[userLocation.matrixItem.id]
if (symbolId == null || symbolManager.annotations.get(symbolId) == null) { if (symbolId == null || symbolManager.annotations.get(symbolId) == null) {
createSymbol(userLocation, symbolManager) createSymbol(userLocation, symbolManager)
@ -126,10 +153,10 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
} }
private fun createSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) { private fun createSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
addUserPinToMapStyle(userLocation.userId, userLocation.pinDrawable) addUserPinToMapStyle(userLocation.matrixItem.id, userLocation.pinDrawable)
val symbolOptions = buildSymbolOptions(userLocation) val symbolOptions = buildSymbolOptions(userLocation)
val symbol = symbolManager.create(symbolOptions) val symbol = symbolManager.create(symbolOptions)
viewModel.handle(LocationLiveMapAction.AddMapSymbol(userLocation.userId, symbol.id)) viewModel.handle(LocationLiveMapAction.AddMapSymbol(userLocation.matrixItem.id, symbol.id))
} }
private fun updateSymbol(symbolId: Long, userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) { private fun updateSymbol(symbolId: Long, userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
@ -142,7 +169,7 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
} }
private fun removeOutdatedSymbols(userLiveLocations: List<UserLiveLocationViewState>, symbolManager: SymbolManager) = withState(viewModel) { state -> private fun removeOutdatedSymbols(userLiveLocations: List<UserLiveLocationViewState>, symbolManager: SymbolManager) = withState(viewModel) { state ->
val userIdsToRemove = state.mapSymbolIds.keys.subtract(userLiveLocations.map { it.userId }.toSet()) val userIdsToRemove = state.mapSymbolIds.keys.subtract(userLiveLocations.map { it.matrixItem.id }.toSet())
userIdsToRemove.forEach { userId -> userIdsToRemove.forEach { userId ->
removeUserPinFromMapStyle(userId) removeUserPinFromMapStyle(userId)
viewModel.handle(LocationLiveMapAction.RemoveMapSymbol(userId)) viewModel.handle(LocationLiveMapAction.RemoveMapSymbol(userId))
@ -187,9 +214,18 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
private fun buildSymbolOptions(userLiveLocation: UserLiveLocationViewState) = private fun buildSymbolOptions(userLiveLocation: UserLiveLocationViewState) =
SymbolOptions() SymbolOptions()
.withLatLng(LatLng(userLiveLocation.locationData.latitude, userLiveLocation.locationData.longitude)) .withLatLng(LatLng(userLiveLocation.locationData.latitude, userLiveLocation.locationData.longitude))
.withIconImage(userLiveLocation.userId) .withIconImage(userLiveLocation.matrixItem.id)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM) .withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
private fun handleBottomSheetUserSelected(userId: String) = withState(viewModel) { state ->
state.userLocations
.find { it.matrixItem.id == userId }
?.locationData
?.let { locationData ->
mapboxMap?.get()?.zoomToLocation(locationData, preserveCurrentZoomLevel = true)
}
}
companion object { companion object {
private const val MAP_FRAGMENT_TAG = "im.vector.app.features.location.live.map" private const val MAP_FRAGMENT_TAG = "im.vector.app.features.location.live.map"
} }

View File

@ -23,13 +23,15 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.location.LocationSharingServiceConnection
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
class LocationLiveMapViewModel @AssistedInject constructor( class LocationLiveMapViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationLiveMapViewState, @Assisted private val initialState: LocationLiveMapViewState,
getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase,
) : VectorViewModel<LocationLiveMapViewState, LocationLiveMapAction, LocationLiveMapViewEvents>(initialState) { private val locationSharingServiceConnection: LocationSharingServiceConnection,
) : VectorViewModel<LocationLiveMapViewState, LocationLiveMapAction, LocationLiveMapViewEvents>(initialState), LocationSharingServiceConnection.Callback {
@AssistedFactory @AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LocationLiveMapViewModel, LocationLiveMapViewState> { interface Factory : MavericksAssistedViewModelFactory<LocationLiveMapViewModel, LocationLiveMapViewState> {
@ -42,12 +44,14 @@ class LocationLiveMapViewModel @AssistedInject constructor(
getListOfUserLiveLocationUseCase.execute(initialState.roomId) getListOfUserLiveLocationUseCase.execute(initialState.roomId)
.onEach { setState { copy(userLocations = it) } } .onEach { setState { copy(userLocations = it) } }
.launchIn(viewModelScope) .launchIn(viewModelScope)
locationSharingServiceConnection.bind(this)
} }
override fun handle(action: LocationLiveMapAction) { override fun handle(action: LocationLiveMapAction) {
when (action) { when (action) {
is LocationLiveMapAction.AddMapSymbol -> handleAddMapSymbol(action) is LocationLiveMapAction.AddMapSymbol -> handleAddMapSymbol(action)
is LocationLiveMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action) is LocationLiveMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action)
LocationLiveMapAction.StopSharing -> handleStopSharing()
} }
} }
@ -64,4 +68,16 @@ class LocationLiveMapViewModel @AssistedInject constructor(
copy(mapSymbolIds = newMapSymbolIds) copy(mapSymbolIds = newMapSymbolIds)
} }
} }
private fun handleStopSharing() {
locationSharingServiceConnection.stopLiveLocationSharing(initialState.roomId)
}
override fun onLocationServiceRunning() {
// NOOP
}
override fun onLocationServiceStopped() {
// NOOP
}
} }

View File

@ -19,6 +19,7 @@ package im.vector.app.features.location.live.map
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationData
import org.matrix.android.sdk.api.util.MatrixItem
data class LocationLiveMapViewState( data class LocationLiveMapViewState(
val roomId: String, val roomId: String,
@ -34,8 +35,10 @@ data class LocationLiveMapViewState(
} }
data class UserLiveLocationViewState( data class UserLiveLocationViewState(
val userId: String, val matrixItem: MatrixItem,
val pinDrawable: Drawable, val pinDrawable: Drawable,
val locationData: LocationData, val locationData: LocationData,
val endOfLiveTimestampMillis: Long? val endOfLiveTimestampMillis: Long?,
val locationTimestampMillis: Long?,
val showStopSharingButton: Boolean
) )

View File

@ -16,14 +16,18 @@
package im.vector.app.features.location.live.map package im.vector.app.features.location.live.map
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.location.toLocationData import im.vector.app.features.location.toLocationData
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.matrix.android.sdk.api.session.getUser
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
import org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject import javax.inject.Inject
class UserLiveLocationViewStateMapper @Inject constructor( class UserLiveLocationViewStateMapper @Inject constructor(
private val locationPinProvider: LocationPinProvider, private val locationPinProvider: LocationPinProvider,
private val activeSessionHolder: ActiveSessionHolder,
) { ) {
suspend fun map(liveLocationShareAggregatedSummary: LiveLocationShareAggregatedSummary) = suspend fun map(liveLocationShareAggregatedSummary: LiveLocationShareAggregatedSummary) =
@ -40,14 +44,20 @@ class UserLiveLocationViewStateMapper @Inject constructor(
} }
else -> { else -> {
locationPinProvider.create(userId) { pinDrawable -> locationPinProvider.create(userId) { pinDrawable ->
val viewState = UserLiveLocationViewState( val session = activeSessionHolder.getActiveSession()
userId = userId, session.getUser(userId)?.toMatrixItem()?.let { matrixItem ->
pinDrawable = pinDrawable, val locationTimestampMillis = liveLocationShareAggregatedSummary.lastLocationDataContent?.getBestTimestampMillis()
locationData = locationData, val viewState = UserLiveLocationViewState(
endOfLiveTimestampMillis = liveLocationShareAggregatedSummary.endOfLiveTimestampMillis matrixItem = matrixItem,
) pinDrawable = pinDrawable,
continuation.resume(viewState) { locationData = locationData,
// do nothing on cancellation endOfLiveTimestampMillis = liveLocationShareAggregatedSummary.endOfLiveTimestampMillis,
locationTimestampMillis = locationTimestampMillis,
showStopSharingButton = userId == session.myUserId
)
continuation.resume(viewState) {
// do nothing on cancellation
}
} }
} }
} }

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8" ?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp" />
<solid android:color="?android:colorBackground" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size android:width="36dp" android:height="6dp" />
<solid android:color="?vctr_content_quinary" />
<corners android:radius="3dp" />
</shape>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_live_location_users_bottom_sheet">
<FrameLayout
android:id="@+id/liveLocationMapFragmentContainer"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/bottomSheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_live_location_users_bottom_sheet"
app:behavior_peekHeight="200dp"
app:behavior_hideable="false"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<View
android:id="@+id/liveLocationMapBottomSheetHandle"
android:layout_width="36dp"
android:layout_height="6dp"
android:layout_marginTop="12dp"
android:background="@drawable/ic_bottom_sheet_handle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/liveLocationBottomSheetRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/liveLocationMapBottomSheetHandle"
tools:listitem="@layout/item_live_location_users_bottom_sheet" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragmentContainer"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="80dp"
android:background="?selectableItemBackground">
<ImageView
android:id="@+id/itemUserAvatarImageView"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@sample/user_round_avatars" />
<TextView
android:id="@+id/itemUserDisplayNameTextView"
style="@style/TextAppearance.Vector.Body.BottomSheetDisplayName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintStart_toEndOf="@id/itemUserAvatarImageView"
app:layout_constraintTop_toTopOf="@id/itemUserAvatarImageView"
tools:text="@sample/live_location_users.json/data/displayName" />
<TextView
android:id="@+id/itemRemainingTimeTextView"
style="@style/TextAppearance.Vector.Body.BottomSheetRemainingTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
app:layout_constraintStart_toStartOf="@id/itemUserDisplayNameTextView"
app:layout_constraintTop_toBottomOf="@id/itemUserDisplayNameTextView"
tools:text="@sample/live_location_users.json/data/remainingTime" />
<TextView
android:id="@+id/itemLastUpdatedAtTextView"
style="@style/TextAppearance.Vector.Body.BottomSheetLastUpdatedAt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
app:layout_constraintStart_toStartOf="@id/itemRemainingTimeTextView"
app:layout_constraintTop_toBottomOf="@id/itemRemainingTimeTextView"
tools:text="@sample/live_location_users.json/data/lastUpdatedAt" />
<Button
android:id="@+id/itemStopSharingButton"
style="@style/Widget.Vector.Button.Text.BottomSheetStopSharing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="@string/live_location_bottom_sheet_stop_sharing"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -3037,6 +3037,8 @@
<string name="live_location_sharing_notification_description">Location sharing is in progress</string> <string name="live_location_sharing_notification_description">Location sharing is in progress</string>
<string name="labs_enable_live_location">Enable Live Location Sharing</string> <string name="labs_enable_live_location">Enable Live Location Sharing</string>
<string name="labs_enable_live_location_summary">Temporary implementation: locations persist in room history</string> <string name="labs_enable_live_location_summary">Temporary implementation: locations persist in room history</string>
<string name="live_location_bottom_sheet_stop_sharing">Stop sharing</string>
<string name="live_location_bottom_sheet_last_updated_at">Updated %1$s ago</string>
<string name="message_bubbles">Show Message bubbles</string> <string name="message_bubbles">Show Message bubbles</string>

View File

@ -35,6 +35,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.api.util.MatrixItem
class GetListOfUserLiveLocationUseCaseTest { class GetListOfUserLiveLocationUseCaseTest {
@ -88,16 +89,20 @@ class GetListOfUserLiveLocationUseCaseTest {
every { liveData.asFlow() } returns flowOf(summaries) every { liveData.asFlow() } returns flowOf(summaries)
val viewState1 = UserLiveLocationViewState( val viewState1 = UserLiveLocationViewState(
userId = "userId1", matrixItem = MatrixItem.UserItem(id = "@userId1:matrix.org", displayName = "User 1", avatarUrl = ""),
pinDrawable = mockk(), pinDrawable = mockk(),
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null), locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
endOfLiveTimestampMillis = 123 endOfLiveTimestampMillis = 123,
locationTimestampMillis = 123,
showStopSharingButton = false
) )
val viewState2 = UserLiveLocationViewState( val viewState2 = UserLiveLocationViewState(
userId = "userId2", matrixItem = MatrixItem.UserItem(id = "@userId2:matrix.org", displayName = "User 2", avatarUrl = ""),
pinDrawable = mockk(), pinDrawable = mockk(),
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null), locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
endOfLiveTimestampMillis = 1234 endOfLiveTimestampMillis = 1234,
locationTimestampMillis = 1234,
showStopSharingButton = false
) )
coEvery { viewStateMapper.map(summary1) } returns viewState1 coEvery { viewStateMapper.map(summary1) } returns viewState1
coEvery { viewStateMapper.map(summary2) } returns viewState2 coEvery { viewStateMapper.map(summary2) } returns viewState2

View File

@ -18,13 +18,18 @@ package im.vector.app.features.location.live.map
import com.airbnb.mvrx.test.MvRxTestRule import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationSharingServiceConnection
import im.vector.app.test.test import im.vector.app.test.test
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.util.MatrixItem
class LocationLiveMapViewModelTest { class LocationLiveMapViewModelTest {
@ -36,11 +41,13 @@ class LocationLiveMapViewModelTest {
private val args = LocationLiveMapViewArgs(roomId = fakeRoomId) private val args = LocationLiveMapViewArgs(roomId = fakeRoomId)
private val getListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>() private val getListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>()
private val locationServiceConnection = mockk<LocationSharingServiceConnection>()
private fun createViewModel(): LocationLiveMapViewModel { private fun createViewModel(): LocationLiveMapViewModel {
return LocationLiveMapViewModel( return LocationLiveMapViewModel(
LocationLiveMapViewState(args), LocationLiveMapViewState(args),
getListOfUserLiveLocationUseCase getListOfUserLiveLocationUseCase,
locationServiceConnection
) )
} }
@ -48,13 +55,15 @@ class LocationLiveMapViewModelTest {
fun `given the viewModel has been initialized then viewState contains user locations list`() = runTest { fun `given the viewModel has been initialized then viewState contains user locations list`() = runTest {
val userLocations = listOf( val userLocations = listOf(
UserLiveLocationViewState( UserLiveLocationViewState(
userId = "", MatrixItem.UserItem(id = "@userId1:matrix.org", displayName = "User 1", avatarUrl = ""),
pinDrawable = mockk(), pinDrawable = mockk(),
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null), locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
endOfLiveTimestampMillis = 123 endOfLiveTimestampMillis = 123,
locationTimestampMillis = 123,
showStopSharingButton = false
) )
) )
every { locationServiceConnection.bind(any()) } just runs
every { getListOfUserLiveLocationUseCase.execute(fakeRoomId) } returns flowOf(userLocations) every { getListOfUserLiveLocationUseCase.execute(fakeRoomId) } returns flowOf(userLocations)
val viewModel = createViewModel() val viewModel = createViewModel()
@ -66,5 +75,7 @@ class LocationLiveMapViewModelTest {
) )
) )
.finish() .finish()
verify { locationServiceConnection.bind(viewModel) }
} }
} }

View File

@ -18,18 +18,31 @@ package im.vector.app.features.location.live.map
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationData
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeLocationPinProvider import im.vector.app.test.fakes.FakeLocationPinProvider
import im.vector.app.test.fakes.FakeSession
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.getUser
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
import org.matrix.android.sdk.api.session.room.model.message.LocationInfo import org.matrix.android.sdk.api.session.room.model.message.LocationInfo
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.api.session.user.model.User
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.toMatrixItem
private const val A_USER_ID = "aUserId" private const val A_USER_ID = "@aUserId:matrix.org"
private const val A_USER_DISPLAY_NAME = "A_USER_DISPLAY_NAME"
private const val A_IS_ACTIVE = true private const val A_IS_ACTIVE = true
private const val A_END_OF_LIVE_TIMESTAMP = 123L private const val A_END_OF_LIVE_TIMESTAMP = 123L
private const val A_LOCATION_TIMESTAMP = 122L
private const val A_LATITUDE = 40.05 private const val A_LATITUDE = 40.05
private const val A_LONGITUDE = 29.24 private const val A_LONGITUDE = 29.24
private const val A_UNCERTAINTY = 30.0 private const val A_UNCERTAINTY = 30.0
@ -38,8 +51,23 @@ private const val A_GEO_URI = "geo:$A_LATITUDE,$A_LONGITUDE;$A_UNCERTAINTY"
class UserLiveLocationViewStateMapperTest { class UserLiveLocationViewStateMapperTest {
private val locationPinProvider = FakeLocationPinProvider() private val locationPinProvider = FakeLocationPinProvider()
private val fakeSession = FakeSession()
private val fakeActiveSessionHolder = FakeActiveSessionHolder(fakeSession)
private val userLiveLocationViewStateMapper = UserLiveLocationViewStateMapper(locationPinProvider.instance) private val userLiveLocationViewStateMapper = UserLiveLocationViewStateMapper(locationPinProvider.instance, fakeActiveSessionHolder.instance)
@Before
fun setup() {
mockkStatic("org.matrix.android.sdk.api.util.MatrixItemKt")
val fakeUser = mockk<User>()
every { fakeSession.getUser(A_USER_ID) } returns fakeUser
every { fakeUser.toMatrixItem() } returns MatrixItem.UserItem(id = A_USER_ID, displayName = A_USER_DISPLAY_NAME, avatarUrl = "")
}
@After
fun tearDown() {
unmockkStatic("org.matrix.android.sdk.api.util.MatrixItemKt")
}
@Test @Test
fun `given a summary with invalid data then result is null`() = runTest { fun `given a summary with invalid data then result is null`() = runTest {
@ -66,7 +94,8 @@ class UserLiveLocationViewStateMapperTest {
val pinDrawable = mockk<Drawable>() val pinDrawable = mockk<Drawable>()
val locationDataContent = MessageBeaconLocationDataContent( val locationDataContent = MessageBeaconLocationDataContent(
locationInfo = LocationInfo(geoUri = A_GEO_URI) locationInfo = LocationInfo(geoUri = A_GEO_URI),
unstableTimestampMillis = A_LOCATION_TIMESTAMP
) )
val summary = LiveLocationShareAggregatedSummary( val summary = LiveLocationShareAggregatedSummary(
userId = A_USER_ID, userId = A_USER_ID,
@ -79,14 +108,16 @@ class UserLiveLocationViewStateMapperTest {
val viewState = userLiveLocationViewStateMapper.map(summary) val viewState = userLiveLocationViewStateMapper.map(summary)
val expectedViewState = UserLiveLocationViewState( val expectedViewState = UserLiveLocationViewState(
userId = A_USER_ID, matrixItem = MatrixItem.UserItem(id = A_USER_ID, displayName = A_USER_DISPLAY_NAME, avatarUrl = ""),
pinDrawable = pinDrawable, pinDrawable = pinDrawable,
locationData = LocationData( locationData = LocationData(
latitude = A_LATITUDE, latitude = A_LATITUDE,
longitude = A_LONGITUDE, longitude = A_LONGITUDE,
uncertainty = A_UNCERTAINTY uncertainty = A_UNCERTAINTY
), ),
endOfLiveTimestampMillis = A_END_OF_LIVE_TIMESTAMP endOfLiveTimestampMillis = A_END_OF_LIVE_TIMESTAMP,
locationTimestampMillis = A_LOCATION_TIMESTAMP,
showStopSharingButton = false
) )
viewState shouldBeEqualTo expectedViewState viewState shouldBeEqualTo expectedViewState
} }