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.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView 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" private const val TILE_URL = "http://10.42.0.1/static/vendor/tiles/{z}/{x}/{y}.png"
@Composable @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 context = LocalContext.current
val mapView = remember { MapView(context) } val mapView = remember { MapView(context) }
val mapRef = remember { mutableStateOf<MapLibreMap?>(null) } val mapRef = remember { mutableStateOf<MapLibreMap?>(null) }
val styleReady = remember { mutableStateOf(false) } val styleReady = remember { mutableStateOf(false) }
val currentOnMaxClick = rememberUpdatedState(onMaxClick)
DisposableEffect(Unit) { DisposableEffect(Unit) {
mapView.onCreate(null) mapView.onCreate(null)
@@ -58,14 +66,25 @@ fun TripMapView(points: List<RoutePoint>, currentIndex: Int, modifier: Modifier
map.setStyle(buildTripStyle()) { _ -> map.setStyle(buildTripStyle()) { _ ->
styleReady.value = true 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). // 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 val map = mapRef.value ?: return@LaunchedEffect
if (!styleReady.value || points.isEmpty()) 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. // Move the cursor marker as the timeline scrubs/plays.
@@ -111,6 +130,15 @@ private fun buildTripStyle(): Style.Builder {
PropertyFactory.circleStrokeWidth(3f), 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() return Style.Builder()
.withSource(raster) .withSource(raster)
.withLayer(rasterLayer) .withLayer(rasterLayer)
@@ -118,9 +146,11 @@ private fun buildTripStyle(): Style.Builder {
.withLayer(routeLayer) .withLayer(routeLayer)
.withSource(cursorSource) .withSource(cursorSource)
.withLayer(cursorLayer) .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 lngLats = points.map { Point.fromLngLat(it.longitude, it.latitude) }
val line = LineString.fromLngLats(lngLats) val line = LineString.fromLngLats(lngLats)
val feature = Feature.fromGeometry(line) val feature = Feature.fromGeometry(line)
@@ -128,6 +158,15 @@ private fun applyRoute(map: MapLibreMap, points: List<RoutePoint>) {
FeatureCollection.fromFeatures(listOf(feature)) 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() val boundsBuilder = LatLngBounds.Builder()
lngLats.forEach { boundsBuilder.include(LatLng(it.latitude(), it.longitude())) } lngLats.forEach { boundsBuilder.include(LatLng(it.latitude(), it.longitude())) }
map.animateCamera(CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), 60), 800) map.animateCamera(CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), 60), 800)
@@ -1,17 +1,23 @@
package com.yarisobd.app 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -36,6 +42,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.yarisobd.app.data.remote.dto.RoutePoint 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 kotlinx.coroutines.delay
import java.util.Locale import java.util.Locale
private val MAX_AMBER = Color(0xFFF6C23E)
@Composable @Composable
fun TripReplayScreen( fun TripReplayScreen(
trip: TripSummary, trip: TripSummary,
@@ -53,6 +63,17 @@ fun TripReplayScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val points = route?.points ?: emptyList() 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 currentIndex by remember(route) { mutableIntStateOf(0) }
var playing by remember(route) { mutableStateOf(false) } var playing by remember(route) { mutableStateOf(false) }
@@ -100,7 +121,18 @@ fun TripReplayScreen(
Text("No route points", color = MaterialTheme.colorScheme.onSurfaceVariant) Text("No route points", color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} else { } 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), pointLabel(points, currentIndex),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, 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)) Spacer(Modifier.height(4.dp))
Slider( BoxWithConstraints(Modifier.fillMaxWidth()) {
value = if (points.isEmpty()) 0f else currentIndex.toFloat(), Slider(
onValueChange = { value = if (points.isEmpty()) 0f else currentIndex.toFloat(),
currentIndex = it.toInt() onValueChange = {
playing = false currentIndex = it.toInt()
}, playing = false
valueRange = 0f..(points.size - 1).coerceAtLeast(1).toFloat(), },
enabled = points.size > 1, 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
}
)
}
}
} }
} }
} }