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.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,10 +168,24 @@ 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))
|
||||||
|
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||||
Slider(
|
Slider(
|
||||||
value = if (points.isEmpty()) 0f else currentIndex.toFloat(),
|
value = if (points.isEmpty()) 0f else currentIndex.toFloat(),
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
@@ -149,6 +195,25 @@ fun TripReplayScreen(
|
|||||||
valueRange = 0f..(points.size - 1).coerceAtLeast(1).toFloat(),
|
valueRange = 0f..(points.size - 1).coerceAtLeast(1).toFloat(),
|
||||||
enabled = points.size > 1,
|
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