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:
commit
4ccd242cbf
|
@ -0,0 +1 @@
|
||||||
|
Live Location Sharing - User List Bottom Sheet
|
|
@ -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>
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"displayName": "Amandine",
|
||||||
|
"remainingTime": "9min left",
|
||||||
|
"lastUpdatedAt": "Updated 12min ago"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayName": "You",
|
||||||
|
"remainingTime": "19min left",
|
||||||
|
"lastUpdatedAt": "Updated 1min ago"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -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()
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,11 +44,16 @@ class UserLiveLocationViewStateMapper @Inject constructor(
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
locationPinProvider.create(userId) { pinDrawable ->
|
locationPinProvider.create(userId) { pinDrawable ->
|
||||||
|
val session = activeSessionHolder.getActiveSession()
|
||||||
|
session.getUser(userId)?.toMatrixItem()?.let { matrixItem ->
|
||||||
|
val locationTimestampMillis = liveLocationShareAggregatedSummary.lastLocationDataContent?.getBestTimestampMillis()
|
||||||
val viewState = UserLiveLocationViewState(
|
val viewState = UserLiveLocationViewState(
|
||||||
userId = userId,
|
matrixItem = matrixItem,
|
||||||
pinDrawable = pinDrawable,
|
pinDrawable = pinDrawable,
|
||||||
locationData = locationData,
|
locationData = locationData,
|
||||||
endOfLiveTimestampMillis = liveLocationShareAggregatedSummary.endOfLiveTimestampMillis
|
endOfLiveTimestampMillis = liveLocationShareAggregatedSummary.endOfLiveTimestampMillis,
|
||||||
|
locationTimestampMillis = locationTimestampMillis,
|
||||||
|
showStopSharingButton = userId == session.myUserId
|
||||||
)
|
)
|
||||||
continuation.resume(viewState) {
|
continuation.resume(viewState) {
|
||||||
// do nothing on cancellation
|
// do nothing on cancellation
|
||||||
|
@ -53,4 +62,5 @@ class UserLiveLocationViewStateMapper @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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" />
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue