Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Live Location Sharing - User List Bottom Sheet [PSF-890] #6170

Merged
merged 16 commits into from
May 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/6170.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Live Location Sharing - User List Bottom Sheet
22 changes: 22 additions & 0 deletions library/ui-styles/src/main/res/values/styles_location.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
14 changes: 14 additions & 0 deletions vector/sampledata/live_location_users.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"data": [
{
"displayName": "Amandine",
"remainingTime": "9min left",
"lastUpdatedAt": "Updated 12min ago"
},
{
"displayName": "You",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the second item used? I only see the first one used in text for preview of the item layout.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is seen in RecyclerView's preview.

"remainingTime": "19min left",
"lastUpdatedAt": "Updated 1min ago"
}
]
}
47 changes: 32 additions & 15 deletions vector/src/main/java/im/vector/app/core/utils/TextUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯 for injecting the 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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Loading