feat(android): max-speed marker + jump on trip replay (parity with web FEAT-16)
This commit is contained in:
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user