diff --git a/changelog.d/6170.feature b/changelog.d/6170.feature
new file mode 100644
index 0000000000..60eab9d6d5
--- /dev/null
+++ b/changelog.d/6170.feature
@@ -0,0 +1 @@
+Live Location Sharing - User List Bottom Sheet
diff --git a/library/ui-styles/src/main/res/values/styles_location.xml b/library/ui-styles/src/main/res/values/styles_location.xml
index 7571265241..9d9fc862f6 100644
--- a/library/ui-styles/src/main/res/values/styles_location.xml
+++ b/library/ui-styles/src/main/res/values/styles_location.xml
@@ -18,4 +18,26 @@
- center
+
+
+
+
+
+
+
+
diff --git a/vector/sampledata/live_location_users.json b/vector/sampledata/live_location_users.json
new file mode 100644
index 0000000000..58d0fb5fa1
--- /dev/null
+++ b/vector/sampledata/live_location_users.json
@@ -0,0 +1,14 @@
+{
+ "data": [
+ {
+ "displayName": "Amandine",
+ "remainingTime": "9min left",
+ "lastUpdatedAt": "Updated 12min ago"
+ },
+ {
+ "displayName": "You",
+ "remainingTime": "19min left",
+ "lastUpdatedAt": "Updated 1min ago"
+ }
+ ]
+}
diff --git a/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt b/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt
index d2f8c4022b..d6af3f5afb 100644
--- a/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt
+++ b/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt
@@ -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()
diff --git a/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt b/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt
index dbd2225909..cbfdf1dfda 100644
--- a/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt
+++ b/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt
@@ -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()
}
diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationBottomSheetController.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationBottomSheetController.kt
new file mode 100644
index 0000000000..c07104e72c
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationBottomSheetController.kt
@@ -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? = null
+ var callback: Callback? = null
+
+ fun setData(userLocations: List) {
+ 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)
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt
new file mode 100644
index 0000000000..336f688434
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt
@@ -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() {
+
+ 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(R.id.itemUserAvatarImageView)
+ val itemUserDisplayNameTextView by bind(R.id.itemUserDisplayNameTextView)
+ val itemRemainingTimeTextView by bind(R.id.itemRemainingTimeTextView)
+ val itemLastUpdatedAtTextView by bind(R.id.itemLastUpdatedAtTextView)
+ val itemStopSharingButton by bind