Start implementing LLS timeline item

This commit is contained in:
ganfra 2026-03-24 16:38:12 +01:00
parent a22c9871e3
commit b082f59f9c
16 changed files with 356 additions and 121 deletions

View file

@ -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,

View file

@ -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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB