feat(android): max-speed marker + jump on trip replay (parity with web FEAT-16)

This commit is contained in:
Mathieu Moran
2026-08-15 20:04:04 -04:00
parent e1afdf2952
commit 9fa5e2e2da
2 changed files with 119 additions and 15 deletions
@@ -6,6 +6,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
@@ -33,11 +34,18 @@ import org.maplibre.android.style.sources.TileSet
private const val TILE_URL = "http://10.42.0.1/static/vendor/tiles/{z}/{x}/{y}.png"
@Composable
fun TripMapView(points: List<RoutePoint>, currentIndex: Int, modifier: Modifier = Modifier) {
fun TripMapView(
points: List<RoutePoint>,
currentIndex: Int,
maxIndex: Int,
modifier: Modifier = Modifier,
onMaxClick: (() -> Unit)? = null,
) {
val context = LocalContext.current
val mapView = remember { MapView(context) }
val mapRef = remember { mutableStateOf<MapLibreMap?>(null) }
val styleReady = remember { mutableStateOf(false) }
val currentOnMaxClick = rememberUpdatedState(onMaxClick)
DisposableEffect(Unit) {
mapView.onCreate(null)
@@ -58,14 +66,25 @@ fun TripMapView(points: List<RoutePoint>, currentIndex: Int, modifier: Modifier
map.setStyle(buildTripStyle()) { _ ->
styleReady.value = true
}
// Tap the amber max-speed marker to jump the timeline to it.
map.addOnMapClickListener { latLng ->
val screen = map.projection.toScreenLocation(latLng)
val hits = map.queryRenderedFeatures(screen, "max-marker")
if (hits.isNotEmpty()) {
currentOnMaxClick.value?.invoke()
true
} else {
false
}
}
}
}
// Draw the route + fit camera once points arrive (and the style is ready).
LaunchedEffect(points, styleReady.value) {
LaunchedEffect(points, styleReady.value, maxIndex) {
val map = mapRef.value ?: return@LaunchedEffect
if (!styleReady.value || points.isEmpty()) return@LaunchedEffect
applyRoute(map, points)
applyRoute(map, points, maxIndex)
}
// Move the cursor marker as the timeline scrubs/plays.
@@ -111,6 +130,15 @@ private fun buildTripStyle(): Style.Builder {
PropertyFactory.circleStrokeWidth(3f),
)
val maxSource = GeoJsonSource("max")
val maxLayer = CircleLayer("max-marker", "max")
maxLayer.setProperties(
PropertyFactory.circleRadius(8f),
PropertyFactory.circleColor(Color.parseColor("#F6C23E")),
PropertyFactory.circleStrokeColor(Color.WHITE),
PropertyFactory.circleStrokeWidth(2f),
)
return Style.Builder()
.withSource(raster)
.withLayer(rasterLayer)
@@ -118,9 +146,11 @@ private fun buildTripStyle(): Style.Builder {
.withLayer(routeLayer)
.withSource(cursorSource)
.withLayer(cursorLayer)
.withSource(maxSource)
.withLayer(maxLayer)
}
private fun applyRoute(map: MapLibreMap, points: List<RoutePoint>) {
private fun applyRoute(map: MapLibreMap, points: List<RoutePoint>, maxIndex: Int) {
val lngLats = points.map { Point.fromLngLat(it.longitude, it.latitude) }
val line = LineString.fromLngLats(lngLats)
val feature = Feature.fromGeometry(line)
@@ -128,6 +158,15 @@ private fun applyRoute(map: MapLibreMap, points: List<RoutePoint>) {
FeatureCollection.fromFeatures(listOf(feature))
)
if (maxIndex in points.indices) {
val mp = points[maxIndex]
map.style?.getSourceAs<GeoJsonSource>("max")?.setGeoJson(
FeatureCollection.fromFeatures(
listOf(Feature.fromGeometry(Point.fromLngLat(mp.longitude, mp.latitude)))
)
)
}
val boundsBuilder = LatLngBounds.Builder()
lngLats.forEach { boundsBuilder.include(LatLng(it.latitude(), it.longitude())) }
map.animateCamera(CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), 60), 800)
@@ -1,17 +1,23 @@
package com.yarisobd.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -36,6 +42,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.yarisobd.app.data.remote.dto.RoutePoint
@@ -45,6 +53,8 @@ import com.yarisobd.app.data.remote.dto.TripSummary
import kotlinx.coroutines.delay
import java.util.Locale
private val MAX_AMBER = Color(0xFFF6C23E)
@Composable
fun TripReplayScreen(
trip: TripSummary,
@@ -53,6 +63,17 @@ fun TripReplayScreen(
onBack: () -> Unit,
) {
val points = route?.points ?: emptyList()
val maxIndex = remember(points) {
var idx = -1
var best = 0.0
for (i in points.indices) {
if (points[i].speed > best) {
best = points[i].speed
idx = i
}
}
if (best > 0.0) idx else -1
}
var currentIndex by remember(route) { mutableIntStateOf(0) }
var playing by remember(route) { mutableStateOf(false) }
@@ -100,7 +121,18 @@ fun TripReplayScreen(
Text("No route points", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
TripMapView(points = points, currentIndex = currentIndex, modifier = Modifier.fillMaxSize())
TripMapView(
points = points,
currentIndex = currentIndex,
maxIndex = maxIndex,
modifier = Modifier.fillMaxSize(),
onMaxClick = {
if (maxIndex >= 0) {
currentIndex = maxIndex
playing = false
}
},
)
}
}
@@ -136,19 +168,52 @@ fun TripReplayScreen(
pointLabel(points, currentIndex),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1
maxLines = 1,
modifier = Modifier.weight(1f)
)
if (maxIndex >= 0) {
Text(
"Max ${"%.0f".format(Locale.US, points[maxIndex].speed)} km/h",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
color = MAX_AMBER,
modifier = Modifier.clickable {
currentIndex = maxIndex
playing = false
}
)
}
}
Spacer(Modifier.height(4.dp))
Slider(
value = if (points.isEmpty()) 0f else currentIndex.toFloat(),
onValueChange = {
currentIndex = it.toInt()
playing = false
},
valueRange = 0f..(points.size - 1).coerceAtLeast(1).toFloat(),
enabled = points.size > 1,
)
BoxWithConstraints(Modifier.fillMaxWidth()) {
Slider(
value = if (points.isEmpty()) 0f else currentIndex.toFloat(),
onValueChange = {
currentIndex = it.toInt()
playing = false
},
valueRange = 0f..(points.size - 1).coerceAtLeast(1).toFloat(),
enabled = points.size > 1,
)
if (maxIndex >= 0 && points.size > 1) {
val ratio = maxIndex.toFloat() / (points.size - 1).toFloat()
val thumbInset = 12.dp
val dot = 10.dp
val x = thumbInset + (maxWidth - thumbInset * 2) * ratio
Box(
Modifier
.align(Alignment.CenterStart)
.offset(x = x - dot / 2)
.size(dot)
.clip(CircleShape)
.background(MAX_AMBER)
.clickable {
currentIndex = maxIndex
playing = false
}
)
}
}
}
}
}