diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index 0657bae634..3ee0af35af 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -9,7 +9,9 @@ package io.element.android.features.location.api import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -22,6 +24,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext 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 coil3.Extras 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. + * + * 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 fun StaticMapView( - lat: Double, - lon: Double, + location: Location?, zoom: Double, pinVariant: PinVariant, contentDescription: String?, modifier: Modifier = Modifier, 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( modifier = modifier, contentAlignment = Alignment.Center ) { - 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 (else it will thrown an exception). - null - } else { - 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 1: Stale location - show stale map with stale pin, no fetching + when { + pinVariant is PinVariant.StaleLocation -> { + StaleMapContent( + pinVariant = pinVariant, + contentDescription = contentDescription, + width = maxWidth, + height = maxHeight, + ) } - ) + // 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() - if (collectedState.value is AsyncImagePainter.State.Success) { +@Composable +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( painter = painter, contentDescription = contentDescription, modifier = Modifier.size(width = maxWidth, height = maxHeight), // 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. - // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. + // a max width and height of 2048 px. We apply ContentScale.Fit to handle this. contentScale = ContentScale.Fit, ) LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this)) - } else { + } + else -> { StaticMapPlaceholder( - showProgress = collectedState.value.isLoading(), - canReload = builder.isServiceAvailable(), + painter = painterResource(R.drawable.blurred_map), + canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error, contentDescription = contentDescription, width = maxWidth, 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 @Composable internal fun StaticMapViewPreview() = ElementPreview { StaticMapView( - lat = 0.0, - lon = 0.0, + location = Location(0.0, 0.0), zoom = 0.0, contentDescription = null, pinVariant = PinVariant.PinnedLocation, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt index 81b80c8dc3..735a25fef9 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -34,7 +35,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable internal fun StaticMapPlaceholder( - showProgress: Boolean, + painter: Painter, canReload: Boolean, contentDescription: String?, width: Dp, @@ -46,17 +47,15 @@ internal fun StaticMapPlaceholder( contentAlignment = Alignment.Center, modifier = modifier .size(width = width, height = height) - .then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick)) + .clickable(enabled = canReload, onClick = onLoadMapClick) ) { Image( - painter = painterResource(id = R.drawable.blurred_map), + painter = painter, contentDescription = contentDescription, contentScale = ContentScale.FillBounds, modifier = Modifier.size(width = width, height = height) ) - if (showProgress) { - CircularProgressIndicator() - } else if (canReload) { + if (canReload) { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -77,13 +76,10 @@ internal fun StaticMapPlaceholderPreview() = ElementPreview { modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - listOf( - true to false, - false to true, - false to false, - ).forEach { (showProgress, canReload) -> + listOf(false, true) + .forEach { canReload -> StaticMapPlaceholder( - showProgress = showProgress, + painter = painterResource(R.drawable.blurred_map), canReload = canReload, contentDescription = null, width = 400.dp, diff --git a/features/location/api/src/main/res/drawable-night/stale_map.png b/features/location/api/src/main/res/drawable-night/stale_map.png new file mode 100644 index 0000000000..9e36759203 Binary files /dev/null and b/features/location/api/src/main/res/drawable-night/stale_map.png differ diff --git a/features/location/api/src/main/res/drawable/stale_map.png b/features/location/api/src/main/res/drawable/stale_map.png new file mode 100644 index 0000000000..87fa0188c9 Binary files /dev/null and b/features/location/api/src/main/res/drawable/stale_map.png differ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 1591cbf6cc..e9a6ce5549 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -57,4 +57,6 @@ sealed interface TimelineEvent { data class EditPoll( val pollStartId: EventId, ) : TimelineItemPollEvent + + data object StopLiveLocationShare : TimelineItemEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 12e4e0b1d1..b83adca5ec 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index b253f45937..421c52c9ef 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt index 605db65da3..505edeef15 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt @@ -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 { /** diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 4fc243864c..1de73f3658 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -73,6 +73,7 @@ fun TimelineItemEventContentView( ) is TimelineItemLocationContent -> TimelineItemLocationView( content = content, + onStopLiveLocationClick = { eventSink(TimelineEvent.StopLiveLocationShare) }, modifier = modifier ) is TimelineItemImageContent -> TimelineItemImageView( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index 576f7bd2d5..c9e3152afb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -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 = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 85fa8a0dc1..a9457edfa4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -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 = "", + ), + ) } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index a5e2f922ad..e2e5d0c03e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -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) ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index f3d70f44e7..9683a2c149 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -35,7 +35,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider 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" diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt index 5c87c5c538..e2309c2210 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -18,8 +18,35 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider 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, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 957b01d1ed..969e27e8a4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -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,