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