diff --git a/android/app/src/main/java/com/yarisobd/app/TripMapView.kt b/android/app/src/main/java/com/yarisobd/app/TripMapView.kt index 004e1be..cda1478 100644 --- a/android/app/src/main/java/com/yarisobd/app/TripMapView.kt +++ b/android/app/src/main/java/com/yarisobd/app/TripMapView.kt @@ -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, currentIndex: Int, modifier: Modifier = Modifier) { +fun TripMapView( + points: List, + currentIndex: Int, + maxIndex: Int, + modifier: Modifier = Modifier, + onMaxClick: (() -> Unit)? = null, +) { val context = LocalContext.current val mapView = remember { MapView(context) } val mapRef = remember { mutableStateOf(null) } val styleReady = remember { mutableStateOf(false) } + val currentOnMaxClick = rememberUpdatedState(onMaxClick) DisposableEffect(Unit) { mapView.onCreate(null) @@ -58,14 +66,25 @@ fun TripMapView(points: List, 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) { +private fun applyRoute(map: MapLibreMap, points: List, 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) { FeatureCollection.fromFeatures(listOf(feature)) ) + if (maxIndex in points.indices) { + val mp = points[maxIndex] + map.style?.getSourceAs("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) diff --git a/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt b/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt index 68f3b84..6bf55a2 100644 --- a/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt @@ -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 + } + ) + } + } } } }