From b082f59f9c00bd0b2c93c833df0fceaac2190ed6 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 24 Mar 2026 16:38:12 +0100 Subject: [PATCH] Start implementing LLS timeline item --- .../features/location/api/StaticMapView.kt | 151 ++++++++++++------ .../api/internal/StaticMapPlaceholder.kt | 20 +-- .../src/main/res/drawable-night/stale_map.png | Bin 0 -> 2663 bytes .../api/src/main/res/drawable/stale_map.png | Bin 0 -> 3310 bytes .../messages/impl/timeline/TimelineEvent.kt | 2 + .../impl/timeline/TimelinePresenter.kt | 1 + .../components/TimelineItemEventRow.kt | 55 ++++--- .../timeline/components/TimestampPosition.kt | 7 +- .../event/TimelineItemEventContentView.kt | 1 + .../event/TimelineItemLocationView.kt | 131 ++++++++++++++- .../event/TimelineItemContentFactory.kt | 26 +-- .../TimelineItemContentMessageFactory.kt | 3 +- .../event/TimelineItemEventContentProvider.kt | 2 +- .../event/TimelineItemLocationContent.kt | 31 +++- .../TimelineItemLocationContentProvider.kt | 46 ++++-- .../TimelineItemContentMessageFactoryTest.kt | 1 - 16 files changed, 356 insertions(+), 121 deletions(-) create mode 100644 features/location/api/src/main/res/drawable-night/stale_map.png create mode 100644 features/location/api/src/main/res/drawable/stale_map.png 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 0000000000000000000000000000000000000000..9e367592037fb72087f0c58d5bd89b01d5ed04af GIT binary patch literal 2663 zcmXX|d0Z3M77mRiDuyULg+_FI;(`JSHdRD2EMf++P(TD)c7qtL$RY|&89>nhB^Z_} zQkEEoN&wm1P>{tRVo>{p=&-neL_i@y7G)Gigm=gH{+PMv+ji#jwKgwxN*Xfz#t>eHhAm2U_pwT^ip4z$WgMk7F( zchKI&o<^&Y=*$Fv3v}J+T|Uu!!$YHEf};-5c!vsB$w=L4u$yB+O4W3}G)>io1E zSuRqw-gzCQyDqb=%%gK|ZeZ2cyGBR!o*k^!@4DCgtgoOTrtfoBkq0WZ!gol*jjCC* zGa6~m>Y~Y5zeUL{uez%Ozoq4A?+S^Di5Ze^4s84vgTeTg_InXa5oBQ!KRffYUtpj? zdwcs(BCUEY88I{4JjYCVF!15Sd6Fg`3A1{@az$zD2x}uPLaQh1Zp3^rWd0K|4`*aF z4vKclS*yZ!>mqh2?U1w3syG08IFv=w^fG25=CLzA#RdEm`n{SyoSUm!oPH)=afYOk z2M$}E5~9OlYI@4UK?-h4F$#GJdQVcGYCgyCc_n=Uvw(mAIV%WBx*y`P za`l(Vb-3c=_{)cuGB`0*+uAI!Dc+oixvQ?jgRfs-iWLfr;6&{Q3M-DG@DOjDn4FjZ zJ~3zFBRmrZ>`*tzPCtCs>kJ8)v>%z#_^6Xe8vIVx9_a?*jOzO(wlp3vYOcFyyWJVR zUBUV?xS?O^qF}sjm{~(W5wl~S5{WO=uXx?O(t&J5%{)@ zC4&PrNeZ~XwJ#vFWFJONy89N58K$u3f0p6j2LzUhG2vg<yx{)3KNkM9*pY(db;azWTs|6-u+$xR@$Aj(v1Aa7j+Nftx`aJ?amA+bC<^j( z6WWnsYvMCP(> z>rHBv6Vq`eIAZO$2yw+gJ?gKON~QJjS3=ZS;8PZI6q9wIP&T|(se(V9Z*ZN02LQ-F9rrY0~XCKR>@e#}%5ikoGt4_ixQ}hgP(OS{bJAQyq7iiWjxW9+DkC z!_g!KetN_*u|9 z+>-rS25-a_WB(M`@GveY%No|W3ilCTR_XT&@s4IxV?0jf)u zZK{|Tr`p#!b=n?NwxM6kobU3uBvUz@19yYF+%F)YE`D@icD9jf7R2Ss`YfeW5*)K) z89H0WN(*dhoFabZluEpJ7drEdxiWZNcB)1JdqToo=k(M!q|;c)7WT+OV#&(S6Td)F z$Byl_;c{pPAuEIjwQn3idlbgt4 z;@TGWZ!&lWN5a>iSGMUBDhw8Gzb@v@$|bc#=0lyrJyTVul@nM1gdoV6W?*u%CaxjR z$dTJ17X3tqM+=3&`1$+as$wynbcrQzuO8&Y=`;oJL2q{@4=LcH{QW$-7LlpoLj?)< z#M)id9Uxm$;{_EIhVtl;3@-b=DBSmQCvs4_7CX64&dMW{_e(=L)#mCJFZ9}Mt|hx? zDG^)(|H1|R{mc$64umSutw3}{i~XUB{3ssXe*q!WlEIHlB@MQ8pz3V?&&5eA7`_DS z$t4Z0JFt@%qMzsI=l@AxjUx#}&3ohkrz6=7688T|-rNvaDU*;rBl!31;pK#T!OpLD zZT($fuU9WPmr*KV@Z@XMdH3s&vIkr`4j)At>-SWKq)Lg`+K%EYBOl21-g zaksFmMdWZJ4Eq5d=iQ;+PDMY)Y7;cq2~mNgTyjFqAJ}NYZc9I!fIfFXJqcM=g7iJN zXX?>+%)t~gwn8!zUFVHnzD&ooSaQi14c{#R5o_PR2zzPxurz!hG~{f-o^b9?{r}yW zR-xuM3!3XE=Go)Od(vuppuH*3ka}FFn z_A`1RkO>0SUn7w~pDnmXV(mId^mZT=nw+GxGs6`k(qz$o&SKvNcq)q7r)hZsdS^Jh zvR*0h3`nwe4A8XTV8-d>J%{dBAzLDSp->Unc-W>pIaL+J$=Fy$WX=GC>SkYb4TWwt z=?UG}Wnfz6HaL>iat8$Dg~s?6Wt;7%F)pP9%m@@KCDen*#5Nc6ZbXkVOml;Ba&lT@ zZThCyY+p7712mh*1Z<@H@a4_xK2QUN+TNvUdAxl%%!=|{ZEW))SoJ(708pZNb!rAi zN6kK6U4nwee$-2CtOmrI-ws+hRY+ow7WUeHe|V(5stpHTHyB`*67%kq#)I zf(>@t)V9aTzF+H4RAj3esct@a2hw)$a&@e9 H2s-s&mXafS literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..87fa0188c9f884944a463a24de5de4306d72c1f2 GIT binary patch literal 3310 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 literal 0 HcmV?d00001 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,