Start implementing LLS timeline item
This commit is contained in:
parent
a22c9871e3
commit
b082f59f9c
16 changed files with 356 additions and 121 deletions
|
|
@ -57,4 +57,6 @@ sealed interface TimelineEvent {
|
|||
data class EditPoll(
|
||||
val pollStartId: EventId,
|
||||
) : TimelineItemPollEvent
|
||||
|
||||
data object StopLiveLocationShare : TimelineItemEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ class TimelinePresenter(
|
|||
is TimelineEvent.EditPoll -> {
|
||||
navigator.navigateToEditPoll(event.pollStartId)
|
||||
}
|
||||
is TimelineEvent.StopLiveLocationShare -> Unit
|
||||
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
|
||||
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
|
||||
delay(event.debounce)
|
||||
|
|
|
|||
|
|
@ -269,7 +269,9 @@ fun TimelineItemEventRow(
|
|||
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) {
|
||||
ThreadSummaryView(
|
||||
modifier = if (event.isMine) {
|
||||
Modifier.align(Alignment.End).padding(end = 16.dp)
|
||||
Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(end = 16.dp)
|
||||
} else {
|
||||
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
|
||||
}.padding(top = 2.dp),
|
||||
|
|
@ -674,6 +676,7 @@ private fun MessageEventBubbleContent(
|
|||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
TimestampPosition.Hidden -> Box(modifier) { content {} }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -763,11 +766,11 @@ private fun MessageEventBubbleContent(
|
|||
}
|
||||
}
|
||||
|
||||
val timestampPosition = when (event.content) {
|
||||
is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemStickerContent,
|
||||
is TimelineItemLocationContent -> TimestampPosition.Overlay
|
||||
val timestampPosition = when (val content = event.content) {
|
||||
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||
is TimelineItemStickerContent -> TimestampPosition.Overlay
|
||||
is TimelineItemLocationContent -> if (content.hideTimestamp) TimestampPosition.Hidden else TimestampPosition.Overlay
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
else -> TimestampPosition.Default
|
||||
}
|
||||
|
|
@ -833,25 +836,27 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
|
|||
groupPosition = TimelineItemGroupPosition.First,
|
||||
threadInfo = TimelineItemThreadInfo.ThreadRoot(
|
||||
latestEventText = "This is the latest message in the thread",
|
||||
summary = ThreadSummary(AsyncData.Success(
|
||||
EmbeddedEventInfo(
|
||||
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
|
||||
content = MessageContent(
|
||||
body = "This is the latest message in the thread",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
threadInfo = null,
|
||||
type = TextMessageType("This is the latest message in the thread", null)
|
||||
),
|
||||
senderId = UserId("@user:id"),
|
||||
senderProfile = ProfileDetails.Ready(
|
||||
displayName = "Alice",
|
||||
avatarUrl = null,
|
||||
displayNameAmbiguous = false,
|
||||
),
|
||||
timestamp = 0L,
|
||||
)
|
||||
), numberOfReplies = 20L)
|
||||
summary = ThreadSummary(
|
||||
AsyncData.Success(
|
||||
EmbeddedEventInfo(
|
||||
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
|
||||
content = MessageContent(
|
||||
body = "This is the latest message in the thread",
|
||||
inReplyTo = null,
|
||||
isEdited = false,
|
||||
threadInfo = null,
|
||||
type = TextMessageType("This is the latest message in the thread", null)
|
||||
),
|
||||
senderId = UserId("@user:id"),
|
||||
senderProfile = ProfileDetails.Ready(
|
||||
displayName = "Alice",
|
||||
avatarUrl = null,
|
||||
displayNameAmbiguous = false,
|
||||
),
|
||||
timestamp = 0L,
|
||||
)
|
||||
), numberOfReplies = 20L
|
||||
)
|
||||
)
|
||||
),
|
||||
displayThreadSummaries = true,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ enum class TimestampPosition {
|
|||
/**
|
||||
* Timestamp should always be rendered below the timeline event content (eg. poll).
|
||||
*/
|
||||
Below;
|
||||
Below,
|
||||
|
||||
/**
|
||||
* Timestamp should be hidden.
|
||||
*/
|
||||
Hidden;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ fun TimelineItemEventContentView(
|
|||
)
|
||||
is TimelineItemLocationContent -> TimelineItemLocationView(
|
||||
content = content,
|
||||
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemImageContent -> TimelineItemImageView(
|
||||
|
|
|
|||
|
|
@ -8,33 +8,147 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.location.api.StaticMapView
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
@Composable
|
||||
fun TimelineItemLocationView(
|
||||
content: TimelineItemLocationContent,
|
||||
onStopLiveLocationClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
StaticMapView(
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
StaticMapView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 188.dp),
|
||||
pinVariant = content.pinVariant,
|
||||
location = content.location,
|
||||
zoom = 15.0,
|
||||
contentDescription = content.description
|
||||
)
|
||||
|
||||
if (content.mode is TimelineItemLocationContent.Mode.Live) {
|
||||
LiveLocationOverlay(
|
||||
mode = content.mode,
|
||||
onStopClick = onStopLiveLocationClick,
|
||||
modifier = Modifier.align(Alignment.BottomStart)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveLocationOverlay(
|
||||
mode: TimelineItemLocationContent.Mode.Live,
|
||||
onStopClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 188.dp),
|
||||
pinVariant = content.pinVariant,
|
||||
lat = content.location.lat,
|
||||
lon = content.location.lon,
|
||||
zoom = 15.0,
|
||||
contentDescription = content.description
|
||||
)
|
||||
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f))
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val iconShape = RoundedCornerShape(8.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = if (mode.isActive) ElementTheme.colors.iconQuaternaryAlpha else Color.Transparent,
|
||||
shape = iconShape,
|
||||
)
|
||||
.background(
|
||||
color = if (mode.isActive) {
|
||||
ElementTheme.colors.bgCanvasDefault
|
||||
} else {
|
||||
ElementTheme.colors.bgSubtleSecondary
|
||||
},
|
||||
shape = iconShape
|
||||
)
|
||||
) {
|
||||
if (mode.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
color = ElementTheme.colors.iconSecondary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(20.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.LocationPinSolid(),
|
||||
contentDescription = null,
|
||||
tint = if (mode.isActive) {
|
||||
ElementTheme.colors.iconAccentPrimary
|
||||
} else {
|
||||
ElementTheme.colors.iconDisabled
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (mode.isActive) "Live location" else "Live location ended",
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
if (mode.isActive) {
|
||||
Text(
|
||||
text = mode.endsAt,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (mode.isActive && mode.canStop) {
|
||||
IconButton(
|
||||
onClick = onStopClick,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = ElementTheme.colors.bgCriticalPrimary,
|
||||
contentColor = ElementTheme.colors.iconOnSolidPrimary,
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Stop(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
|
@ -43,5 +157,6 @@ internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocat
|
|||
ElementPreview {
|
||||
TimelineItemLocationView(
|
||||
content = content,
|
||||
onStopLiveLocationClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -103,18 +105,18 @@ class TimelineItemContentFactory(
|
|||
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
|
||||
Location.fromGeoUri(beacon.geoUri)
|
||||
}.lastOrNull()
|
||||
if (lastKnownLocation != null) {
|
||||
TimelineItemLocationContent(
|
||||
description = itemContent.description?.trimEnd(),
|
||||
assetType = itemContent.assetType,
|
||||
senderId = sender,
|
||||
senderProfile = senderProfile,
|
||||
location = lastKnownLocation,
|
||||
mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive)
|
||||
)
|
||||
} else {
|
||||
TimelineItemUnknownContent
|
||||
}
|
||||
// Always create content - location can be null for "loading/waiting" state
|
||||
TimelineItemLocationContent(
|
||||
description = itemContent.description?.trimEnd(),
|
||||
assetType = itemContent.assetType,
|
||||
senderId = sender,
|
||||
senderProfile = senderProfile,
|
||||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
lastKnownLocation = lastKnownLocation,
|
||||
isActive = itemContent.isLive,
|
||||
endsAt = "",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,12 +150,11 @@ class TimelineItemContentMessageFactory(
|
|||
)
|
||||
} else {
|
||||
TimelineItemLocationContent(
|
||||
location = location,
|
||||
description = messageType.description,
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
assetType = messageType.assetType,
|
||||
mode = TimelineItemLocationContent.Mode.Static
|
||||
mode = TimelineItemLocationContent.Mode.Static(location = location)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
|||
aTimelineItemUnknownContent(),
|
||||
aTimelineItemTextContent().copy(isEdited = true),
|
||||
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
|
||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
|
||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true, endsAt = "Ends at 12:34", lastKnownLocation = null)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,23 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayNam
|
|||
data class TimelineItemLocationContent(
|
||||
val senderId: UserId,
|
||||
val senderProfile: ProfileDetails,
|
||||
val location: Location,
|
||||
val description: String? = null,
|
||||
val assetType: AssetType? = null,
|
||||
val mode: Mode,
|
||||
) : TimelineItemEventContent {
|
||||
val pinVariant = when (mode) {
|
||||
|
||||
val hideTimestamp = mode is Mode.Live && mode.canStop
|
||||
|
||||
val location = when (mode) {
|
||||
is Mode.Live -> mode.lastKnownLocation
|
||||
is Mode.Static -> mode.location
|
||||
}
|
||||
|
||||
/**
|
||||
* The pin variant to display on the map.
|
||||
* Returns a default variant when location is null (map will show loading placeholder anyway).
|
||||
*/
|
||||
val pinVariant: PinVariant = when (mode) {
|
||||
is Mode.Live -> {
|
||||
if (mode.isActive) {
|
||||
PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true)
|
||||
|
|
@ -34,7 +45,7 @@ data class TimelineItemLocationContent(
|
|||
PinVariant.StaleLocation
|
||||
}
|
||||
}
|
||||
Mode.Static -> {
|
||||
is Mode.Static -> {
|
||||
when (assetType) {
|
||||
AssetType.PIN -> PinVariant.PinnedLocation
|
||||
AssetType.SENDER,
|
||||
|
|
@ -52,8 +63,18 @@ data class TimelineItemLocationContent(
|
|||
)
|
||||
|
||||
sealed interface Mode {
|
||||
data object Static : Mode
|
||||
data class Live(val isActive: Boolean) : Mode
|
||||
data class Static(
|
||||
val location: Location,
|
||||
) : Mode
|
||||
|
||||
data class Live(
|
||||
val lastKnownLocation: Location?,
|
||||
val isActive: Boolean,
|
||||
val endsAt: String,
|
||||
val canStop: Boolean = false
|
||||
) : Mode {
|
||||
val isLoading = lastKnownLocation == null && isActive
|
||||
}
|
||||
}
|
||||
|
||||
override val type: String = "TimelineItemLocationContent"
|
||||
|
|
|
|||
|
|
@ -18,8 +18,35 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
|||
override val values: Sequence<TimelineItemLocationContent>
|
||||
get() = sequenceOf(
|
||||
aTimelineItemLocationContent(),
|
||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
|
||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)),
|
||||
aTimelineItemLocationContent(
|
||||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = true,
|
||||
endsAt = "Ends at 12:34",
|
||||
canStop = true,
|
||||
lastKnownLocation = aLocation()
|
||||
),
|
||||
),
|
||||
aTimelineItemLocationContent(
|
||||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = true,
|
||||
endsAt = "Ends at 12:34",
|
||||
lastKnownLocation = aLocation()
|
||||
),
|
||||
),
|
||||
aTimelineItemLocationContent(
|
||||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = true,
|
||||
endsAt = "Ends at 12:34",
|
||||
lastKnownLocation = null
|
||||
),
|
||||
),
|
||||
aTimelineItemLocationContent(
|
||||
mode = TimelineItemLocationContent.Mode.Live(
|
||||
isActive = false,
|
||||
endsAt = "",
|
||||
lastKnownLocation = aLocation()
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -27,15 +54,16 @@ fun aTimelineItemLocationContent(
|
|||
senderId: UserId = UserId("@sender:matrix.org"),
|
||||
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
||||
description: String? = null,
|
||||
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static,
|
||||
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()),
|
||||
) = TimelineItemLocationContent(
|
||||
location = Location(
|
||||
lat = 52.2445,
|
||||
lon = 0.7186,
|
||||
accuracy = 5000f,
|
||||
),
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
description = description,
|
||||
mode = mode
|
||||
mode = mode,
|
||||
)
|
||||
|
||||
fun aLocation() = Location(
|
||||
lat = 52.2445,
|
||||
lon = 0.7186,
|
||||
accuracy = 5000f,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -110,7 +110,6 @@ class TimelineItemContentMessageFactoryTest {
|
|||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemLocationContent(
|
||||
body = "body",
|
||||
location = Location(lat = 1.0, lon = 2.0, accuracy = null),
|
||||
description = "description",
|
||||
assetType = assetType,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue