Skip to content

Commit e5f8559

Browse files
authored
Merge pull request #6611 from element-hq/feature/fga/live_location_rendering
WIP : live location rendering
2 parents 96cbe76 + 51733ec commit e5f8559

74 files changed

Lines changed: 1252 additions & 269 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,7 @@ sealed interface ShowLocationMode : Parcelable {
2424
) : ShowLocationMode
2525

2626
@Parcelize
27-
data object Live : ShowLocationMode
27+
data class Live(
28+
val senderId: UserId
29+
) : ShowLocationMode
2830
}

features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt

Lines changed: 105 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
package io.element.android.features.location.api
1010

1111
import androidx.compose.foundation.Image
12+
import androidx.compose.foundation.layout.Box
1213
import androidx.compose.foundation.layout.BoxWithConstraints
14+
import androidx.compose.foundation.layout.BoxWithConstraintsScope
1315
import androidx.compose.foundation.layout.size
1416
import androidx.compose.runtime.Composable
1517
import androidx.compose.runtime.collectAsState
@@ -22,6 +24,8 @@ import androidx.compose.ui.Modifier
2224
import androidx.compose.ui.layout.ContentScale
2325
import androidx.compose.ui.platform.LocalContext
2426
import androidx.compose.ui.platform.LocalDensity
27+
import androidx.compose.ui.res.painterResource
28+
import androidx.compose.ui.unit.Dp
2529
import androidx.compose.ui.unit.dp
2630
import coil3.Extras
2731
import coil3.compose.AsyncImagePainter
@@ -38,11 +42,16 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
3842

3943
/**
4044
* Shows a static map image downloaded via a third party service's static maps API.
45+
*
46+
* Handles 4 distinct cases:
47+
* 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching
48+
* 2. Null location - shows blurred placeholder, no pin, no loading
49+
* 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator
50+
* 4. Success (location != null, loaded) - shows actual map with pin
4151
*/
4252
@Composable
4353
fun StaticMapView(
44-
lat: Double,
45-
lon: Double,
54+
location: Location?,
4655
zoom: Double,
4756
pinVariant: PinVariant,
4857
contentDescription: String?,
@@ -56,50 +65,111 @@ fun StaticMapView(
5665
modifier = modifier,
5766
contentAlignment = Alignment.Center
5867
) {
59-
val context = LocalContext.current
60-
var retryHash by remember { mutableIntStateOf(0) }
61-
val builder = remember { StaticMapUrlBuilder() }
62-
val painter = rememberAsyncImagePainter(
63-
model = if (constraints.isZero) {
64-
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
65-
null
66-
} else {
67-
ImageRequest.Builder(context)
68-
.data(
69-
builder.build(
70-
lat = lat,
71-
lon = lon,
72-
zoom = zoom,
73-
darkMode = darkMode,
74-
width = constraints.maxWidth,
75-
height = constraints.maxHeight,
76-
density = LocalDensity.current.density,
77-
)
78-
)
79-
.size(width = constraints.maxWidth, height = constraints.maxHeight)
80-
.apply {
81-
extras.set(Extras.Key("retry_hash"), retryHash).build()
82-
}
83-
.build()
68+
// Case 1: Stale location - show stale map with stale pin, no fetching
69+
when {
70+
pinVariant is PinVariant.StaleLocation -> {
71+
StaleMapContent(
72+
pinVariant = pinVariant,
73+
contentDescription = contentDescription,
74+
width = maxWidth,
75+
height = maxHeight,
76+
)
8477
}
78+
// Case 2: Null location - show blurred placeholder, no pin, no loading
79+
location == null -> {
80+
StaticMapPlaceholder(
81+
painter = painterResource(R.drawable.blurred_map),
82+
canReload = false,
83+
contentDescription = contentDescription,
84+
width = maxWidth,
85+
height = maxHeight,
86+
onLoadMapClick = {}
87+
)
88+
}
89+
// Cases 3 & 4: Non-null location - fetch map
90+
else -> LoadableMapContent(
91+
location = location,
92+
zoom = zoom,
93+
pinVariant = pinVariant,
94+
contentDescription = contentDescription,
95+
darkMode = darkMode,
96+
)
97+
}
98+
}
99+
}
100+
101+
@Composable
102+
private fun BoxWithConstraintsScope.StaleMapContent(
103+
pinVariant: PinVariant,
104+
contentDescription: String?,
105+
width: Dp,
106+
height: Dp,
107+
) {
108+
Box(contentAlignment = Alignment.Center) {
109+
Image(
110+
painter = painterResource(R.drawable.stale_map),
111+
contentDescription = contentDescription,
112+
contentScale = ContentScale.FillBounds,
113+
modifier = Modifier.size(width = width, height = height)
85114
)
115+
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this@StaleMapContent))
116+
}
117+
}
86118

87-
val collectedState = painter.state.collectAsState()
88-
if (collectedState.value is AsyncImagePainter.State.Success) {
119+
@Composable
120+
private fun BoxWithConstraintsScope.LoadableMapContent(
121+
location: Location,
122+
zoom: Double,
123+
pinVariant: PinVariant,
124+
contentDescription: String?,
125+
darkMode: Boolean,
126+
) {
127+
val context = LocalContext.current
128+
var retryHash by remember { mutableIntStateOf(0) }
129+
val builder = remember { StaticMapUrlBuilder() }
130+
131+
val painter = rememberAsyncImagePainter(
132+
model = if (constraints.isZero) {
133+
// Avoid building a URL if any of the size constraints is zero
134+
null
135+
} else {
136+
ImageRequest.Builder(context)
137+
.data(
138+
builder.build(
139+
lat = location.lat,
140+
lon = location.lon,
141+
zoom = zoom,
142+
darkMode = darkMode,
143+
width = constraints.maxWidth,
144+
height = constraints.maxHeight,
145+
density = LocalDensity.current.density,
146+
)
147+
)
148+
.size(width = constraints.maxWidth, height = constraints.maxHeight)
149+
.apply {
150+
extras.set(Extras.Key("retry_hash"), retryHash).build()
151+
}
152+
.build()
153+
}
154+
)
155+
156+
val state by painter.state.collectAsState()
157+
when (state) {
158+
is AsyncImagePainter.State.Success -> {
89159
Image(
90160
painter = painter,
91161
contentDescription = contentDescription,
92162
modifier = Modifier.size(width = maxWidth, height = maxHeight),
93163
// The returned image can be smaller than the requested size due to the static maps API having
94-
// a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details.
95-
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
164+
// a max width and height of 2048 px. We apply ContentScale.Fit to handle this.
96165
contentScale = ContentScale.Fit,
97166
)
98167
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
99-
} else {
168+
}
169+
else -> {
100170
StaticMapPlaceholder(
101-
showProgress = collectedState.value.isLoading(),
102-
canReload = builder.isServiceAvailable(),
171+
painter = painterResource(R.drawable.blurred_map),
172+
canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error,
103173
contentDescription = contentDescription,
104174
width = maxWidth,
105175
height = maxHeight,
@@ -109,17 +179,11 @@ fun StaticMapView(
109179
}
110180
}
111181

112-
private fun AsyncImagePainter.State.isLoading(): Boolean {
113-
return this is AsyncImagePainter.State.Empty ||
114-
this is AsyncImagePainter.State.Loading
115-
}
116-
117182
@PreviewsDayNight
118183
@Composable
119184
internal fun StaticMapViewPreview() = ElementPreview {
120185
StaticMapView(
121-
lat = 0.0,
122-
lon = 0.0,
186+
location = Location(0.0, 0.0),
123187
zoom = 0.0,
124188
contentDescription = null,
125189
pinVariant = PinVariant.PinnedLocation,

features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
1818
import androidx.compose.runtime.Composable
1919
import androidx.compose.ui.Alignment
2020
import androidx.compose.ui.Modifier
21+
import androidx.compose.ui.graphics.painter.Painter
2122
import androidx.compose.ui.layout.ContentScale
2223
import androidx.compose.ui.res.painterResource
2324
import androidx.compose.ui.res.stringResource
@@ -27,14 +28,13 @@ import io.element.android.compound.tokens.generated.CompoundIcons
2728
import io.element.android.features.location.api.R
2829
import io.element.android.libraries.designsystem.preview.ElementPreview
2930
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
30-
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
3131
import io.element.android.libraries.designsystem.theme.components.Icon
3232
import io.element.android.libraries.designsystem.theme.components.Text
3333
import io.element.android.libraries.ui.strings.CommonStrings
3434

3535
@Composable
3636
internal fun StaticMapPlaceholder(
37-
showProgress: Boolean,
37+
painter: Painter,
3838
canReload: Boolean,
3939
contentDescription: String?,
4040
width: Dp,
@@ -46,17 +46,15 @@ internal fun StaticMapPlaceholder(
4646
contentAlignment = Alignment.Center,
4747
modifier = modifier
4848
.size(width = width, height = height)
49-
.then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick))
49+
.clickable(enabled = canReload, onClick = onLoadMapClick)
5050
) {
5151
Image(
52-
painter = painterResource(id = R.drawable.blurred_map),
52+
painter = painter,
5353
contentDescription = contentDescription,
5454
contentScale = ContentScale.FillBounds,
5555
modifier = Modifier.size(width = width, height = height)
5656
)
57-
if (showProgress) {
58-
CircularProgressIndicator()
59-
} else if (canReload) {
57+
if (canReload) {
6058
Column(
6159
horizontalAlignment = Alignment.CenterHorizontally,
6260
) {
@@ -77,13 +75,10 @@ internal fun StaticMapPlaceholderPreview() = ElementPreview {
7775
modifier = Modifier.padding(8.dp),
7876
verticalArrangement = Arrangement.spacedBy(8.dp)
7977
) {
80-
listOf(
81-
true to false,
82-
false to true,
83-
false to false,
84-
).forEach { (showProgress, canReload) ->
78+
listOf(false, true)
79+
.forEach { canReload ->
8580
StaticMapPlaceholder(
86-
showProgress = showProgress,
81+
painter = painterResource(R.drawable.blurred_map),
8782
canReload = canReload,
8883
contentDescription = null,
8984
width = 400.dp,
2.6 KB
Loading
3.23 KB
Loading

features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,9 @@ fun LocationShareRow(
9191
)
9292
}
9393
Text(
94-
text = item.formattedTimestamp,
94+
text = if (item.isLive) stringResource(CommonStrings.screen_room_live_location_banner) else item.formattedTimestamp,
9595
style = ElementTheme.typography.fontBodySmRegular,
96-
color = ElementTheme.colors.textSecondary,
96+
color = if (item.isLive) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary,
9797
maxLines = 1,
9898
overflow = TextOverflow.Ellipsis,
9999
)

features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ package io.element.android.features.location.impl.common.ui
1010
import androidx.compose.foundation.layout.Box
1111
import androidx.compose.foundation.layout.BoxScope
1212
import androidx.compose.foundation.layout.BoxWithConstraints
13+
import androidx.compose.foundation.layout.Column
1314
import androidx.compose.foundation.layout.ColumnScope
1415
import androidx.compose.foundation.layout.PaddingValues
1516
import androidx.compose.foundation.layout.Spacer
1617
import androidx.compose.foundation.layout.WindowInsets
1718
import androidx.compose.foundation.layout.WindowInsetsSides
1819
import androidx.compose.foundation.layout.fillMaxSize
20+
import androidx.compose.foundation.layout.heightIn
1921
import androidx.compose.foundation.layout.navigationBars
2022
import androidx.compose.foundation.layout.only
2123
import androidx.compose.foundation.layout.safeDrawing
@@ -43,6 +45,7 @@ import androidx.compose.ui.unit.max
4345
import io.element.android.features.location.api.internal.rememberTileStyleUrl
4446
import io.element.android.features.location.impl.common.MapDefaults
4547
import io.element.android.libraries.core.data.tryOrNull
48+
import io.element.android.libraries.designsystem.text.toDp
4649
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
4750
import org.maplibre.compose.camera.CameraState
4851
import org.maplibre.compose.camera.rememberCameraState
@@ -112,8 +115,11 @@ fun MapBottomSheetScaffold(
112115
modifier = Modifier,
113116
sheetPeekHeight = sheetPeekHeight,
114117
sheetContent = {
115-
sheetContent(sheetPadding)
116-
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
118+
val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp()
119+
Column(modifier = Modifier.heightIn(max = maxContentHeight)) {
120+
sheetContent(sheetPadding)
121+
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
122+
}
117123
},
118124
scaffoldState = scaffoldState,
119125
sheetDragHandle = sheetDragHandle,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright (c) 2026 Element Creations Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.location.impl.show
9+
10+
import io.element.android.libraries.matrix.api.core.UserId
11+
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
12+
13+
class LiveLocationShareComparator(private val currentUser: UserId) : Comparator<LiveLocationShare> {
14+
override fun compare(p0: LiveLocationShare, p1: LiveLocationShare): Int {
15+
val p0IsCurrentUser = p0.userId == currentUser
16+
val p1IsCurrentUser = p1.userId == currentUser
17+
if (p0IsCurrentUser != p1IsCurrentUser) return if (p0IsCurrentUser) -1 else 1
18+
return p1.startTimestamp.compareTo(p0.startTimestamp)
19+
}
20+
}

0 commit comments

Comments
 (0)