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>
</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>

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.text.format.Formatter
import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import org.threeten.bp.Duration
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 minutes = getMinutes(duration)
val seconds = getSeconds(duration)
val builder = StringBuilder()
when {
hours > 0 -> {
appendHours(context, builder, hours)
appendHours(getString, builder, hours)
if (minutes > 0) {
builder.append(" ")
appendMinutes(context, builder, minutes)
appendMinutes(getString, builder, minutes)
}
if (seconds > 0) {
if (appendSeconds && seconds > 0) {
builder.append(" ")
appendSeconds(context, builder, seconds)
appendSeconds(getString, builder, seconds)
}
}
minutes > 0 -> {
appendMinutes(context, builder, minutes)
if (seconds > 0) {
appendMinutes(getString, builder, minutes)
if (appendSeconds && seconds > 0) {
builder.append(" ")
appendSeconds(context, builder, seconds)
appendSeconds(getString, builder, seconds)
}
}
else -> {
appendSeconds(context, builder, seconds)
appendSeconds(getString, builder, seconds)
}
}
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(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(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(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()

View file

@ -22,10 +22,15 @@ import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.geometry.LatLngBounds
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()
.target(LatLng(locationData.latitude, locationData.longitude))
.zoom(INITIAL_MAP_ZOOM_IN_PREVIEW)
.zoom(zoomLevel)
.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 {
data class AddMapSymbol(val key: String, val value: Long) : 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
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.lifecycleScope
@ -36,8 +38,9 @@ import com.mapbox.mapboxsdk.style.layers.Property
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
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.databinding.FragmentSimpleContainerBinding
import im.vector.app.databinding.FragmentLocationLiveMapViewBinding
import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.zoomToBounds
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.
*/
@AndroidEntryPoint
class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<FragmentSimpleContainerBinding>() {
class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<FragmentLocationLiveMapViewBinding>() {
@Inject lateinit var urlMapProvider: UrlMapProvider
@Inject lateinit var bottomSheetController: LiveLocationBottomSheetController
private val viewModel: LocationLiveMapViewModel by fragmentViewModel()
@ -62,8 +67,23 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
private val pendingLiveLocations = mutableListOf<UserLiveLocationViewState>()
private var isMapFirstUpdate = true
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSimpleContainerBinding {
return FragmentSimpleContainerBinding.inflate(layoutInflater, container, false)
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationLiveMapViewBinding {
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() {
@ -78,7 +98,9 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
mapboxMap.setStyle(urlMapProvider.getMapUrl()) { style ->
mapStyle = style
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
.takeUnless { it.isEmpty() }
?.let { updateMap(it) }
@ -92,11 +114,16 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
?: run {
val options = MapboxMapOptions.createFromAttributes(requireContext(), null)
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 ->
updateMap(viewState.userLocations)
updateUserListBottomSheet(viewState.userLocations)
}
private fun updateUserListBottomSheet(userLocations: List<UserLiveLocationViewState>) {
bottomSheetController.setData(userLocations)
}
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 ->
val symbolId = state.mapSymbolIds[userLocation.userId]
val symbolId = state.mapSymbolIds[userLocation.matrixItem.id]
if (symbolId == null || symbolManager.annotations.get(symbolId) == null) {
createSymbol(userLocation, symbolManager)
@ -126,10 +153,10 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
}
private fun createSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
addUserPinToMapStyle(userLocation.userId, userLocation.pinDrawable)
addUserPinToMapStyle(userLocation.matrixItem.id, userLocation.pinDrawable)
val symbolOptions = buildSymbolOptions(userLocation)
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) {
@ -142,7 +169,7 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
}
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 ->
removeUserPinFromMapStyle(userId)
viewModel.handle(LocationLiveMapAction.RemoveMapSymbol(userId))
@ -187,9 +214,18 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
private fun buildSymbolOptions(userLiveLocation: UserLiveLocationViewState) =
SymbolOptions()
.withLatLng(LatLng(userLiveLocation.locationData.latitude, userLiveLocation.locationData.longitude))
.withIconImage(userLiveLocation.userId)
.withIconImage(userLiveLocation.matrixItem.id)
.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 {
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.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.location.LocationSharingServiceConnection
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class LocationLiveMapViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationLiveMapViewState,
getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase
) : VectorViewModel<LocationLiveMapViewState, LocationLiveMapAction, LocationLiveMapViewEvents>(initialState) {
getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase,
private val locationSharingServiceConnection: LocationSharingServiceConnection,
) : VectorViewModel<LocationLiveMapViewState, LocationLiveMapAction, LocationLiveMapViewEvents>(initialState), LocationSharingServiceConnection.Callback {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LocationLiveMapViewModel, LocationLiveMapViewState> {
@ -42,12 +44,14 @@ class LocationLiveMapViewModel @AssistedInject constructor(
getListOfUserLiveLocationUseCase.execute(initialState.roomId)
.onEach { setState { copy(userLocations = it) } }
.launchIn(viewModelScope)
locationSharingServiceConnection.bind(this)
}
override fun handle(action: LocationLiveMapAction) {
when (action) {
is LocationLiveMapAction.AddMapSymbol -> handleAddMapSymbol(action)
is LocationLiveMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action)
LocationLiveMapAction.StopSharing -> handleStopSharing()
}
}
@ -64,4 +68,16 @@ class LocationLiveMapViewModel @AssistedInject constructor(
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 com.airbnb.mvrx.MavericksState
import im.vector.app.features.location.LocationData
import org.matrix.android.sdk.api.util.MatrixItem
data class LocationLiveMapViewState(
val roomId: String,
@ -34,8 +35,10 @@ data class LocationLiveMapViewState(
}
data class UserLiveLocationViewState(
val userId: String,
val matrixItem: MatrixItem,
val pinDrawable: Drawable,
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
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.location.toLocationData
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.util.toMatrixItem
import javax.inject.Inject
class UserLiveLocationViewStateMapper @Inject constructor(
private val locationPinProvider: LocationPinProvider,
private val activeSessionHolder: ActiveSessionHolder,
) {
suspend fun map(liveLocationShareAggregatedSummary: LiveLocationShareAggregatedSummary) =
@ -40,14 +44,20 @@ class UserLiveLocationViewStateMapper @Inject constructor(
}
else -> {
locationPinProvider.create(userId) { pinDrawable ->
val viewState = UserLiveLocationViewState(
userId = userId,
pinDrawable = pinDrawable,
locationData = locationData,
endOfLiveTimestampMillis = liveLocationShareAggregatedSummary.endOfLiveTimestampMillis
)
continuation.resume(viewState) {
// do nothing on cancellation
val session = activeSessionHolder.getActiveSession()
session.getUser(userId)?.toMatrixItem()?.let { matrixItem ->
val locationTimestampMillis = liveLocationShareAggregatedSummary.lastLocationDataContent?.getBestTimestampMillis()
val viewState = UserLiveLocationViewState(
matrixItem = matrixItem,
pinDrawable = pinDrawable,
locationData = locationData,
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="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="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>

View file

@ -35,6 +35,7 @@ import org.junit.Rule
import org.junit.Test
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.util.MatrixItem
class GetListOfUserLiveLocationUseCaseTest {
@ -88,16 +89,20 @@ class GetListOfUserLiveLocationUseCaseTest {
every { liveData.asFlow() } returns flowOf(summaries)
val viewState1 = UserLiveLocationViewState(
userId = "userId1",
matrixItem = MatrixItem.UserItem(id = "@userId1:matrix.org", displayName = "User 1", avatarUrl = ""),
pinDrawable = mockk(),
locationData = LocationData(latitude = 1.0, longitude = 2.0, uncertainty = null),
endOfLiveTimestampMillis = 123
endOfLiveTimestampMillis = 123,
locationTimestampMillis = 123,
showStopSharingButton = false
)
val viewState2 = UserLiveLocationViewState(
userId = "userId2",
matrixItem = MatrixItem.UserItem(id = "@userId2:matrix.org", displayName = "User 2", avatarUrl = ""),
pinDrawable = mockk(),
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(summary2) } returns viewState2

View file

@ -18,13 +18,18 @@ package im.vector.app.features.location.live.map
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationSharingServiceConnection
import im.vector.app.test.test
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.util.MatrixItem
class LocationLiveMapViewModelTest {
@ -36,11 +41,13 @@ class LocationLiveMapViewModelTest {
private val args = LocationLiveMapViewArgs(roomId = fakeRoomId)
private val getListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>()
private val locationServiceConnection = mockk<LocationSharingServiceConnection>()
private fun createViewModel(): LocationLiveMapViewModel {
return LocationLiveMapViewModel(
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 {
val userLocations = listOf(
UserLiveLocationViewState(
userId = "",
MatrixItem.UserItem(id = "@userId1:matrix.org", displayName = "User 1", avatarUrl = ""),
pinDrawable = mockk(),
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)
val viewModel = createViewModel()
@ -66,5 +75,7 @@ class LocationLiveMapViewModelTest {
)
)
.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 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.FakeSession
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
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.message.LocationInfo
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_END_OF_LIVE_TIMESTAMP = 123L
private const val A_LOCATION_TIMESTAMP = 122L
private const val A_LATITUDE = 40.05
private const val A_LONGITUDE = 29.24
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 {
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
fun `given a summary with invalid data then result is null`() = runTest {
@ -66,7 +94,8 @@ class UserLiveLocationViewStateMapperTest {
val pinDrawable = mockk<Drawable>()
val locationDataContent = MessageBeaconLocationDataContent(
locationInfo = LocationInfo(geoUri = A_GEO_URI)
locationInfo = LocationInfo(geoUri = A_GEO_URI),
unstableTimestampMillis = A_LOCATION_TIMESTAMP
)
val summary = LiveLocationShareAggregatedSummary(
userId = A_USER_ID,
@ -79,14 +108,16 @@ class UserLiveLocationViewStateMapperTest {
val viewState = userLiveLocationViewStateMapper.map(summary)
val expectedViewState = UserLiveLocationViewState(
userId = A_USER_ID,
matrixItem = MatrixItem.UserItem(id = A_USER_ID, displayName = A_USER_DISPLAY_NAME, avatarUrl = ""),
pinDrawable = pinDrawable,
locationData = LocationData(
latitude = A_LATITUDE,
longitude = A_LONGITUDE,
uncertainty = A_UNCERTAINTY
),
endOfLiveTimestampMillis = A_END_OF_LIVE_TIMESTAMP
endOfLiveTimestampMillis = A_END_OF_LIVE_TIMESTAMP,
locationTimestampMillis = A_LOCATION_TIMESTAMP,
showStopSharingButton = false
)
viewState shouldBeEqualTo expectedViewState
}