Start implementing LLS timeline item
This commit is contained in:
parent
a217c1fcdf
commit
678663f9e8
16 changed files with 356 additions and 121 deletions
|
|
@ -9,7 +9,9 @@
|
||||||
package io.element.android.features.location.api
|
package io.element.android.features.location.api
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -22,6 +24,8 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.Extras
|
import coil3.Extras
|
||||||
import coil3.compose.AsyncImagePainter
|
import coil3.compose.AsyncImagePainter
|
||||||
|
|
@ -38,68 +42,131 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a static map image downloaded via a third party service's static maps API.
|
* Shows a static map image downloaded via a third party service's static maps API.
|
||||||
|
*
|
||||||
|
* Handles 4 distinct cases:
|
||||||
|
* 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching
|
||||||
|
* 2. Null location - shows blurred placeholder, no pin, no loading
|
||||||
|
* 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator
|
||||||
|
* 4. Success (location != null, loaded) - shows actual map with pin
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun StaticMapView(
|
fun StaticMapView(
|
||||||
lat: Double,
|
location: Location?,
|
||||||
lon: Double,
|
|
||||||
zoom: Double,
|
zoom: Double,
|
||||||
pinVariant: PinVariant,
|
pinVariant: PinVariant,
|
||||||
contentDescription: String?,
|
contentDescription: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
darkMode: Boolean = !ElementTheme.isLightTheme,
|
darkMode: Boolean = !ElementTheme.isLightTheme,
|
||||||
) {
|
) {
|
||||||
// Using BoxWithConstraints to:
|
|
||||||
// 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints.
|
|
||||||
// 2) Request the static map image of the exact required size in Px to fill the AsyncImage.
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
// Case 1: Stale location - show stale map with stale pin, no fetching
|
||||||
var retryHash by remember { mutableIntStateOf(0) }
|
when {
|
||||||
val builder = remember { StaticMapUrlBuilder() }
|
pinVariant is PinVariant.StaleLocation -> {
|
||||||
val painter = rememberAsyncImagePainter(
|
StaleMapContent(
|
||||||
model = if (constraints.isZero) {
|
pinVariant = pinVariant,
|
||||||
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
|
contentDescription = contentDescription,
|
||||||
null
|
width = maxWidth,
|
||||||
} else {
|
height = maxHeight,
|
||||||
ImageRequest.Builder(context)
|
)
|
||||||
.data(
|
|
||||||
builder.build(
|
|
||||||
lat = lat,
|
|
||||||
lon = lon,
|
|
||||||
zoom = zoom,
|
|
||||||
darkMode = darkMode,
|
|
||||||
width = constraints.maxWidth,
|
|
||||||
height = constraints.maxHeight,
|
|
||||||
density = LocalDensity.current.density,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.size(width = constraints.maxWidth, height = constraints.maxHeight)
|
|
||||||
.apply {
|
|
||||||
extras.set(Extras.Key("retry_hash"), retryHash).build()
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
)
|
// Case 2: Null location - show blurred placeholder, no pin, no loading
|
||||||
|
location == null -> {
|
||||||
|
StaticMapPlaceholder(
|
||||||
|
painter = painterResource(R.drawable.blurred_map),
|
||||||
|
canReload = false,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
width = maxWidth,
|
||||||
|
height = maxHeight,
|
||||||
|
onLoadMapClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Cases 3 & 4: Non-null location - fetch map
|
||||||
|
else -> LoadableMapContent(
|
||||||
|
location = location,
|
||||||
|
zoom = zoom,
|
||||||
|
pinVariant = pinVariant,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
darkMode = darkMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val collectedState = painter.state.collectAsState()
|
@Composable
|
||||||
if (collectedState.value is AsyncImagePainter.State.Success) {
|
private fun BoxWithConstraintsScope.StaleMapContent(
|
||||||
|
pinVariant: PinVariant,
|
||||||
|
contentDescription: String?,
|
||||||
|
width: Dp,
|
||||||
|
height: Dp,
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.stale_map),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = ContentScale.FillBounds,
|
||||||
|
modifier = Modifier.size(width = width, height = height)
|
||||||
|
)
|
||||||
|
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this@StaleMapContent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoxWithConstraintsScope.LoadableMapContent(
|
||||||
|
location: Location,
|
||||||
|
zoom: Double,
|
||||||
|
pinVariant: PinVariant,
|
||||||
|
contentDescription: String?,
|
||||||
|
darkMode: Boolean,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var retryHash by remember { mutableIntStateOf(0) }
|
||||||
|
val builder = remember { StaticMapUrlBuilder() }
|
||||||
|
|
||||||
|
val painter = rememberAsyncImagePainter(
|
||||||
|
model = if (constraints.isZero) {
|
||||||
|
// Avoid building a URL if any of the size constraints is zero
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(
|
||||||
|
builder.build(
|
||||||
|
lat = location.lat,
|
||||||
|
lon = location.lon,
|
||||||
|
zoom = zoom,
|
||||||
|
darkMode = darkMode,
|
||||||
|
width = constraints.maxWidth,
|
||||||
|
height = constraints.maxHeight,
|
||||||
|
density = LocalDensity.current.density,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.size(width = constraints.maxWidth, height = constraints.maxHeight)
|
||||||
|
.apply {
|
||||||
|
extras.set(Extras.Key("retry_hash"), retryHash).build()
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val state by painter.state.collectAsState()
|
||||||
|
when (state) {
|
||||||
|
is AsyncImagePainter.State.Success -> {
|
||||||
Image(
|
Image(
|
||||||
painter = painter,
|
painter = painter,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = Modifier.size(width = maxWidth, height = maxHeight),
|
modifier = Modifier.size(width = maxWidth, height = maxHeight),
|
||||||
// The returned image can be smaller than the requested size due to the static maps API having
|
// The returned image can be smaller than the requested size due to the static maps API having
|
||||||
// a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details.
|
// a max width and height of 2048 px. We apply ContentScale.Fit to handle this.
|
||||||
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
|
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
|
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
|
||||||
} else {
|
}
|
||||||
|
else -> {
|
||||||
StaticMapPlaceholder(
|
StaticMapPlaceholder(
|
||||||
showProgress = collectedState.value.isLoading(),
|
painter = painterResource(R.drawable.blurred_map),
|
||||||
canReload = builder.isServiceAvailable(),
|
canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
width = maxWidth,
|
width = maxWidth,
|
||||||
height = maxHeight,
|
height = maxHeight,
|
||||||
|
|
@ -109,17 +176,11 @@ fun StaticMapView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AsyncImagePainter.State.isLoading(): Boolean {
|
|
||||||
return this is AsyncImagePainter.State.Empty ||
|
|
||||||
this is AsyncImagePainter.State.Loading
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun StaticMapViewPreview() = ElementPreview {
|
internal fun StaticMapViewPreview() = ElementPreview {
|
||||||
StaticMapView(
|
StaticMapView(
|
||||||
lat = 0.0,
|
location = Location(0.0, 0.0),
|
||||||
lon = 0.0,
|
|
||||||
zoom = 0.0,
|
zoom = 0.0,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
pinVariant = PinVariant.PinnedLocation,
|
pinVariant = PinVariant.PinnedLocation,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -34,7 +35,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun StaticMapPlaceholder(
|
internal fun StaticMapPlaceholder(
|
||||||
showProgress: Boolean,
|
painter: Painter,
|
||||||
canReload: Boolean,
|
canReload: Boolean,
|
||||||
contentDescription: String?,
|
contentDescription: String?,
|
||||||
width: Dp,
|
width: Dp,
|
||||||
|
|
@ -46,17 +47,15 @@ internal fun StaticMapPlaceholder(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(width = width, height = height)
|
.size(width = width, height = height)
|
||||||
.then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick))
|
.clickable(enabled = canReload, onClick = onLoadMapClick)
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = R.drawable.blurred_map),
|
painter = painter,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
contentScale = ContentScale.FillBounds,
|
contentScale = ContentScale.FillBounds,
|
||||||
modifier = Modifier.size(width = width, height = height)
|
modifier = Modifier.size(width = width, height = height)
|
||||||
)
|
)
|
||||||
if (showProgress) {
|
if (canReload) {
|
||||||
CircularProgressIndicator()
|
|
||||||
} else if (canReload) {
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
|
@ -77,13 +76,10 @@ internal fun StaticMapPlaceholderPreview() = ElementPreview {
|
||||||
modifier = Modifier.padding(8.dp),
|
modifier = Modifier.padding(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
listOf(
|
listOf(false, true)
|
||||||
true to false,
|
.forEach { canReload ->
|
||||||
false to true,
|
|
||||||
false to false,
|
|
||||||
).forEach { (showProgress, canReload) ->
|
|
||||||
StaticMapPlaceholder(
|
StaticMapPlaceholder(
|
||||||
showProgress = showProgress,
|
painter = painterResource(R.drawable.blurred_map),
|
||||||
canReload = canReload,
|
canReload = canReload,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
width = 400.dp,
|
width = 400.dp,
|
||||||
|
|
|
||||||
BIN
features/location/api/src/main/res/drawable-night/stale_map.png
Normal file
BIN
features/location/api/src/main/res/drawable-night/stale_map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
features/location/api/src/main/res/drawable/stale_map.png
Normal file
BIN
features/location/api/src/main/res/drawable/stale_map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
|
|
@ -57,4 +57,6 @@ sealed interface TimelineEvent {
|
||||||
data class EditPoll(
|
data class EditPoll(
|
||||||
val pollStartId: EventId,
|
val pollStartId: EventId,
|
||||||
) : TimelineItemPollEvent
|
) : TimelineItemPollEvent
|
||||||
|
|
||||||
|
data object StopLiveLocationShare : TimelineItemEvent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,7 @@ class TimelinePresenter(
|
||||||
is TimelineEvent.EditPoll -> {
|
is TimelineEvent.EditPoll -> {
|
||||||
navigator.navigateToEditPoll(event.pollStartId)
|
navigator.navigateToEditPoll(event.pollStartId)
|
||||||
}
|
}
|
||||||
|
is TimelineEvent.StopLiveLocationShare -> Unit
|
||||||
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
|
is TimelineEvent.FocusOnEvent -> sessionCoroutineScope.launch {
|
||||||
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
|
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
|
||||||
delay(event.debounce)
|
delay(event.debounce)
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,9 @@ fun TimelineItemEventRow(
|
||||||
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) {
|
if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) {
|
||||||
ThreadSummaryView(
|
ThreadSummaryView(
|
||||||
modifier = if (event.isMine) {
|
modifier = if (event.isMine) {
|
||||||
Modifier.align(Alignment.End).padding(end = 16.dp)
|
Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(end = 16.dp)
|
||||||
} else {
|
} else {
|
||||||
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
|
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
|
||||||
}.padding(top = 2.dp),
|
}.padding(top = 2.dp),
|
||||||
|
|
@ -674,6 +676,7 @@ private fun MessageEventBubbleContent(
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
TimestampPosition.Hidden -> Box(modifier) { content {} }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -763,11 +766,11 @@ private fun MessageEventBubbleContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val timestampPosition = when (event.content) {
|
val timestampPosition = when (val content = event.content) {
|
||||||
is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
is TimelineItemImageContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||||
is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
is TimelineItemVideoContent -> if (content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay
|
||||||
is TimelineItemStickerContent,
|
is TimelineItemStickerContent -> TimestampPosition.Overlay
|
||||||
is TimelineItemLocationContent -> TimestampPosition.Overlay
|
is TimelineItemLocationContent -> if (content.hideTimestamp) TimestampPosition.Hidden else TimestampPosition.Overlay
|
||||||
is TimelineItemPollContent -> TimestampPosition.Below
|
is TimelineItemPollContent -> TimestampPosition.Below
|
||||||
else -> TimestampPosition.Default
|
else -> TimestampPosition.Default
|
||||||
}
|
}
|
||||||
|
|
@ -833,25 +836,27 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
|
||||||
groupPosition = TimelineItemGroupPosition.First,
|
groupPosition = TimelineItemGroupPosition.First,
|
||||||
threadInfo = TimelineItemThreadInfo.ThreadRoot(
|
threadInfo = TimelineItemThreadInfo.ThreadRoot(
|
||||||
latestEventText = "This is the latest message in the thread",
|
latestEventText = "This is the latest message in the thread",
|
||||||
summary = ThreadSummary(AsyncData.Success(
|
summary = ThreadSummary(
|
||||||
EmbeddedEventInfo(
|
AsyncData.Success(
|
||||||
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
|
EmbeddedEventInfo(
|
||||||
content = MessageContent(
|
eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")),
|
||||||
body = "This is the latest message in the thread",
|
content = MessageContent(
|
||||||
inReplyTo = null,
|
body = "This is the latest message in the thread",
|
||||||
isEdited = false,
|
inReplyTo = null,
|
||||||
threadInfo = null,
|
isEdited = false,
|
||||||
type = TextMessageType("This is the latest message in the thread", null)
|
threadInfo = null,
|
||||||
),
|
type = TextMessageType("This is the latest message in the thread", null)
|
||||||
senderId = UserId("@user:id"),
|
),
|
||||||
senderProfile = ProfileDetails.Ready(
|
senderId = UserId("@user:id"),
|
||||||
displayName = "Alice",
|
senderProfile = ProfileDetails.Ready(
|
||||||
avatarUrl = null,
|
displayName = "Alice",
|
||||||
displayNameAmbiguous = false,
|
avatarUrl = null,
|
||||||
),
|
displayNameAmbiguous = false,
|
||||||
timestamp = 0L,
|
),
|
||||||
)
|
timestamp = 0L,
|
||||||
), numberOfReplies = 20L)
|
)
|
||||||
|
), numberOfReplies = 20L
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
displayThreadSummaries = true,
|
displayThreadSummaries = true,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,12 @@ enum class TimestampPosition {
|
||||||
/**
|
/**
|
||||||
* Timestamp should always be rendered below the timeline event content (eg. poll).
|
* Timestamp should always be rendered below the timeline event content (eg. poll).
|
||||||
*/
|
*/
|
||||||
Below;
|
Below,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp should be hidden.
|
||||||
|
*/
|
||||||
|
Hidden;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ fun TimelineItemEventContentView(
|
||||||
)
|
)
|
||||||
is TimelineItemLocationContent -> TimelineItemLocationView(
|
is TimelineItemLocationContent -> TimelineItemLocationView(
|
||||||
content = content,
|
content = content,
|
||||||
|
onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) },
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
is TimelineItemImageContent -> TimelineItemImageView(
|
is TimelineItemImageContent -> TimelineItemImageView(
|
||||||
|
|
|
||||||
|
|
@ -8,33 +8,147 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
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.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
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.location.api.StaticMapView
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
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.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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
|
@Composable
|
||||||
fun TimelineItemLocationView(
|
fun TimelineItemLocationView(
|
||||||
content: TimelineItemLocationContent,
|
content: TimelineItemLocationContent,
|
||||||
|
onStopLiveLocationClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = 188.dp),
|
.background(ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.9f))
|
||||||
pinVariant = content.pinVariant,
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
lat = content.location.lat,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
lon = content.location.lon,
|
) {
|
||||||
zoom = 15.0,
|
val iconShape = RoundedCornerShape(8.dp)
|
||||||
contentDescription = content.description
|
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
|
@PreviewsDayNight
|
||||||
|
|
@ -43,5 +157,6 @@ internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocat
|
||||||
ElementPreview {
|
ElementPreview {
|
||||||
TimelineItemLocationView(
|
TimelineItemLocationView(
|
||||||
content = content,
|
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.TimelineItemLocationContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
|
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.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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
@ -103,18 +105,18 @@ class TimelineItemContentFactory(
|
||||||
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
|
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
|
||||||
Location.fromGeoUri(beacon.geoUri)
|
Location.fromGeoUri(beacon.geoUri)
|
||||||
}.lastOrNull()
|
}.lastOrNull()
|
||||||
if (lastKnownLocation != null) {
|
// Always create content - location can be null for "loading/waiting" state
|
||||||
TimelineItemLocationContent(
|
TimelineItemLocationContent(
|
||||||
description = itemContent.description?.trimEnd(),
|
description = itemContent.description?.trimEnd(),
|
||||||
assetType = itemContent.assetType,
|
assetType = itemContent.assetType,
|
||||||
senderId = sender,
|
senderId = sender,
|
||||||
senderProfile = senderProfile,
|
senderProfile = senderProfile,
|
||||||
location = lastKnownLocation,
|
mode = TimelineItemLocationContent.Mode.Live(
|
||||||
mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive)
|
lastKnownLocation = lastKnownLocation,
|
||||||
)
|
isActive = itemContent.isLive,
|
||||||
} else {
|
endsAt = "",
|
||||||
TimelineItemUnknownContent
|
),
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,12 +150,11 @@ class TimelineItemContentMessageFactory(
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
TimelineItemLocationContent(
|
TimelineItemLocationContent(
|
||||||
location = location,
|
|
||||||
description = messageType.description,
|
description = messageType.description,
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
senderProfile = senderProfile,
|
senderProfile = senderProfile,
|
||||||
assetType = messageType.assetType,
|
assetType = messageType.assetType,
|
||||||
mode = TimelineItemLocationContent.Mode.Static
|
mode = TimelineItemLocationContent.Mode.Static(location = location)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
||||||
aTimelineItemUnknownContent(),
|
aTimelineItemUnknownContent(),
|
||||||
aTimelineItemTextContent().copy(isEdited = true),
|
aTimelineItemTextContent().copy(isEdited = true),
|
||||||
aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT),
|
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(
|
data class TimelineItemLocationContent(
|
||||||
val senderId: UserId,
|
val senderId: UserId,
|
||||||
val senderProfile: ProfileDetails,
|
val senderProfile: ProfileDetails,
|
||||||
val location: Location,
|
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val assetType: AssetType? = null,
|
val assetType: AssetType? = null,
|
||||||
val mode: Mode,
|
val mode: Mode,
|
||||||
) : TimelineItemEventContent {
|
) : 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 -> {
|
is Mode.Live -> {
|
||||||
if (mode.isActive) {
|
if (mode.isActive) {
|
||||||
PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true)
|
PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true)
|
||||||
|
|
@ -34,7 +45,7 @@ data class TimelineItemLocationContent(
|
||||||
PinVariant.StaleLocation
|
PinVariant.StaleLocation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Mode.Static -> {
|
is Mode.Static -> {
|
||||||
when (assetType) {
|
when (assetType) {
|
||||||
AssetType.PIN -> PinVariant.PinnedLocation
|
AssetType.PIN -> PinVariant.PinnedLocation
|
||||||
AssetType.SENDER,
|
AssetType.SENDER,
|
||||||
|
|
@ -52,8 +63,18 @@ data class TimelineItemLocationContent(
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface Mode {
|
sealed interface Mode {
|
||||||
data object Static : Mode
|
data class Static(
|
||||||
data class Live(val isActive: Boolean) : Mode
|
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"
|
override val type: String = "TimelineItemLocationContent"
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,35 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
||||||
override val values: Sequence<TimelineItemLocationContent>
|
override val values: Sequence<TimelineItemLocationContent>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aTimelineItemLocationContent(),
|
aTimelineItemLocationContent(),
|
||||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)),
|
aTimelineItemLocationContent(
|
||||||
aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)),
|
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"),
|
senderId: UserId = UserId("@sender:matrix.org"),
|
||||||
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static,
|
mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static(aLocation()),
|
||||||
) = TimelineItemLocationContent(
|
) = TimelineItemLocationContent(
|
||||||
location = Location(
|
|
||||||
lat = 52.2445,
|
|
||||||
lon = 0.7186,
|
|
||||||
accuracy = 5000f,
|
|
||||||
),
|
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
senderProfile = senderProfile,
|
senderProfile = senderProfile,
|
||||||
description = description,
|
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,
|
eventId = AN_EVENT_ID,
|
||||||
)
|
)
|
||||||
val expected = TimelineItemLocationContent(
|
val expected = TimelineItemLocationContent(
|
||||||
body = "body",
|
|
||||||
location = Location(lat = 1.0, lon = 2.0, accuracy = null),
|
location = Location(lat = 1.0, lon = 2.0, accuracy = null),
|
||||||
description = "description",
|
description = "description",
|
||||||
assetType = assetType,
|
assetType = assetType,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue