feat(android): trip history list + route replay map (Phase 28.5)
This commit is contained in:
@@ -19,6 +19,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.DirectionsCar
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,11 +27,13 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.yarisobd.app.data.remote.dto.TripSummary
|
||||
import com.yarisobd.app.ui.theme.YarisOBDTheme
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@@ -60,6 +63,13 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
val piOnline by dashboardVm.piOnline.collectAsState()
|
||||
val serverOnline by dashboardVm.serverOnline.collectAsState()
|
||||
val lastUpdate by dashboardVm.lastUpdate.collectAsState()
|
||||
val tripVm: TripViewModel = viewModel()
|
||||
val trips by tripVm.trips.collectAsState()
|
||||
val tripLabels by tripVm.tripLabels.collectAsState()
|
||||
val tripsLoading by tripVm.isLoading.collectAsState()
|
||||
val tripsError by tripVm.error.collectAsState()
|
||||
val route by tripVm.route.collectAsState()
|
||||
val routeLoading by tripVm.routeLoading.collectAsState()
|
||||
val videos by dashcamVm.videos.collectAsState()
|
||||
val totalSize by dashcamVm.totalSize.collectAsState()
|
||||
val retentionDays by dashcamVm.retentionDays.collectAsState()
|
||||
@@ -81,6 +91,7 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
var selectedTrip by remember { mutableStateOf<TripSummary?>(null) }
|
||||
|
||||
// Show download/delete events as snackbars
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -89,6 +100,17 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTrip != null) {
|
||||
TripReplayScreen(
|
||||
trip = selectedTrip!!,
|
||||
route = route,
|
||||
loading = routeLoading,
|
||||
onBack = {
|
||||
selectedTrip = null
|
||||
tripVm.clearRoute()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
@@ -114,6 +136,12 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 3,
|
||||
onClick = { selectedTab = 3 },
|
||||
icon = { Icon(Icons.Filled.DirectionsCar, contentDescription = "Trips") },
|
||||
label = { Text("Trips") }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 4,
|
||||
onClick = { selectedTab = 4 },
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
|
||||
label = { Text("Settings") }
|
||||
)
|
||||
@@ -166,7 +194,19 @@ fun YarisMainScreen(client: OkHttpClient) {
|
||||
onRefresh = { dashcamVm.scanDownloads() },
|
||||
)
|
||||
3 -> SettingsScreen(client = client)
|
||||
4 -> TripListScreen(
|
||||
trips = trips,
|
||||
labels = tripLabels,
|
||||
isLoading = tripsLoading,
|
||||
error = tripsError,
|
||||
onTripClick = { t ->
|
||||
selectedTrip = t
|
||||
tripVm.loadRoute(t.id)
|
||||
},
|
||||
onRefresh = { tripVm.loadTrips() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.yarisobd.app.data.remote.dto.TripSummary
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TripListScreen(
|
||||
trips: List<TripSummary>,
|
||||
labels: Map<Int, TripLabels>,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onTripClick: (TripSummary) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
// One retry on first composition if the initial load failed (e.g. app started
|
||||
// before the phone joined the Pi hotspot).
|
||||
LaunchedEffect(Unit) {
|
||||
if (trips.isEmpty() && !isLoading) onRefresh()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
com.yarisobd.app.ui.components.YarisHeader()
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"${trips.size} trips",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Button(onClick = onRefresh, enabled = !isLoading) {
|
||||
Text("Refresh")
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
isLoading && trips.isEmpty() -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
error != null && trips.isEmpty() -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("🚗", style = MaterialTheme.typography.headlineLarge)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = onRefresh) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
}
|
||||
trips.isEmpty() -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"No trips recorded yet",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(trips, key = { it.id }) { trip ->
|
||||
TripCard(trip, labels[trip.id], onClick = { onTripClick(trip) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TripCard(trip: TripSummary, label: TripLabels?, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(14.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
dateText(trip.start_datetime),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
timeText(trip.start_datetime) + " → " + timeText(trip.end_datetime),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"%.1f km".format(Locale.US, trip.distance_km),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
TripStat("Duration", durationText(trip.duration_seconds), Modifier.weight(1f))
|
||||
TripStat("Avg", "%.0f km/h".format(Locale.US, trip.average_speed_kmh), Modifier.weight(1f))
|
||||
TripStat("Max", "%.0f km/h".format(Locale.US, trip.max_speed_kmh), Modifier.weight(1f))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
(label?.start ?: coordText(trip.start)) + " → " + (label?.end ?: coordText(trip.end)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TripStat(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dateText(dt: String): String = if (dt.length >= 10) dt.substring(0, 10) else dt
|
||||
private fun timeText(dt: String): String = if (dt.length >= 16) dt.substring(11, 16) else dt
|
||||
|
||||
private fun durationText(seconds: Int): String {
|
||||
if (seconds < 60) return "${seconds}s"
|
||||
val h = seconds / 3600
|
||||
val m = (seconds % 3600) / 60
|
||||
return if (h > 0) "${h}h ${m}m" else "${m}m"
|
||||
}
|
||||
|
||||
private fun coordText(ll: com.yarisobd.app.data.remote.dto.LatLon?): String {
|
||||
val lat = ll?.latitude ?: return "—"
|
||||
val lon = ll?.longitude ?: return "—"
|
||||
return "%.4f, %.4f".format(Locale.US, lat, lon)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import android.graphics.Color
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.maplibre.geojson.Feature
|
||||
import org.maplibre.geojson.FeatureCollection
|
||||
import org.maplibre.geojson.LineString
|
||||
import org.maplibre.geojson.Point
|
||||
import com.yarisobd.app.data.remote.dto.RoutePoint
|
||||
import org.maplibre.android.camera.CameraUpdateFactory
|
||||
import org.maplibre.android.geometry.LatLng
|
||||
import org.maplibre.android.geometry.LatLngBounds
|
||||
import org.maplibre.android.maps.MapLibreMap
|
||||
import org.maplibre.android.maps.MapView
|
||||
import org.maplibre.android.maps.Style
|
||||
import org.maplibre.android.style.layers.CircleLayer
|
||||
import org.maplibre.android.style.layers.LineLayer
|
||||
import org.maplibre.android.style.layers.Property
|
||||
import org.maplibre.android.style.layers.PropertyFactory
|
||||
import org.maplibre.android.style.layers.RasterLayer
|
||||
import org.maplibre.android.style.sources.GeoJsonSource
|
||||
import org.maplibre.android.style.sources.RasterSource
|
||||
import org.maplibre.android.style.sources.TileSet
|
||||
|
||||
/** Pi serves offline MapTiler raster tiles (zoom 8–15) at this path. */
|
||||
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) {
|
||||
val context = LocalContext.current
|
||||
val mapView = remember { MapView(context) }
|
||||
val mapRef = remember { mutableStateOf<MapLibreMap?>(null) }
|
||||
val styleReady = remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
mapView.onCreate(null)
|
||||
mapView.onStart()
|
||||
mapView.onResume()
|
||||
onDispose {
|
||||
mapView.onPause()
|
||||
mapView.onStop()
|
||||
mapView.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
mapView.getMapAsync { map ->
|
||||
mapRef.value = map
|
||||
map.setMinZoomPreference(8.0)
|
||||
map.setMaxZoomPreference(15.0)
|
||||
map.setStyle(buildTripStyle()) { _ ->
|
||||
styleReady.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the route + fit camera once points arrive (and the style is ready).
|
||||
LaunchedEffect(points, styleReady.value) {
|
||||
val map = mapRef.value ?: return@LaunchedEffect
|
||||
if (!styleReady.value || points.isEmpty()) return@LaunchedEffect
|
||||
applyRoute(map, points)
|
||||
}
|
||||
|
||||
// Move the cursor marker as the timeline scrubs/plays.
|
||||
LaunchedEffect(currentIndex, styleReady.value, points) {
|
||||
val map = mapRef.value ?: return@LaunchedEffect
|
||||
if (!styleReady.value || points.isEmpty()) return@LaunchedEffect
|
||||
val idx = currentIndex.coerceIn(0, points.size - 1)
|
||||
val p = points[idx]
|
||||
map.style?.getSourceAs<GeoJsonSource>("cursor")?.setGeoJson(
|
||||
FeatureCollection.fromFeatures(
|
||||
listOf(Feature.fromGeometry(Point.fromLngLat(p.longitude, p.latitude)))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
AndroidView(factory = { mapView }, modifier = modifier)
|
||||
}
|
||||
|
||||
private fun buildTripStyle(): Style.Builder {
|
||||
val tileSet = TileSet("2.1.0", TILE_URL)
|
||||
tileSet.minZoom = 8f
|
||||
tileSet.maxZoom = 15f
|
||||
tileSet.attribution = "MapTiler"
|
||||
|
||||
val raster = RasterSource("tiles", tileSet, 256)
|
||||
val rasterLayer = RasterLayer("tiles-layer", "tiles")
|
||||
|
||||
val routeSource = GeoJsonSource("route")
|
||||
val routeLayer = LineLayer("route-layer", "route")
|
||||
routeLayer.setProperties(
|
||||
PropertyFactory.lineColor(Color.parseColor("#E63946")),
|
||||
PropertyFactory.lineWidth(5f),
|
||||
PropertyFactory.lineCap(Property.LINE_CAP_ROUND),
|
||||
PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND),
|
||||
)
|
||||
|
||||
val cursorSource = GeoJsonSource("cursor")
|
||||
val cursorLayer = CircleLayer("cursor-layer", "cursor")
|
||||
cursorLayer.setProperties(
|
||||
PropertyFactory.circleRadius(8f),
|
||||
PropertyFactory.circleColor(Color.WHITE),
|
||||
PropertyFactory.circleStrokeColor(Color.parseColor("#E63946")),
|
||||
PropertyFactory.circleStrokeWidth(3f),
|
||||
)
|
||||
|
||||
return Style.Builder()
|
||||
.withSource(raster)
|
||||
.withLayer(rasterLayer)
|
||||
.withSource(routeSource)
|
||||
.withLayer(routeLayer)
|
||||
.withSource(cursorSource)
|
||||
.withLayer(cursorLayer)
|
||||
}
|
||||
|
||||
private fun applyRoute(map: MapLibreMap, points: List<RoutePoint>) {
|
||||
val lngLats = points.map { Point.fromLngLat(it.longitude, it.latitude) }
|
||||
val line = LineString.fromLngLats(lngLats)
|
||||
val feature = Feature.fromGeometry(line)
|
||||
map.style?.getSourceAs<GeoJsonSource>("route")?.setGeoJson(
|
||||
FeatureCollection.fromFeatures(listOf(feature))
|
||||
)
|
||||
|
||||
val boundsBuilder = LatLngBounds.Builder()
|
||||
lngLats.forEach { boundsBuilder.include(LatLng(it.latitude(), it.longitude())) }
|
||||
map.animateCamera(CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), 60), 800)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.yarisobd.app.data.remote.dto.RoutePoint
|
||||
import com.yarisobd.app.data.remote.dto.TripMeta
|
||||
import com.yarisobd.app.data.remote.dto.TripRoute
|
||||
import com.yarisobd.app.data.remote.dto.TripSummary
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TripReplayScreen(
|
||||
trip: TripSummary,
|
||||
route: TripRoute?,
|
||||
loading: Boolean,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val points = route?.points ?: emptyList()
|
||||
var currentIndex by remember(route) { mutableIntStateOf(0) }
|
||||
var playing by remember(route) { mutableStateOf(false) }
|
||||
|
||||
// Animate the scrubber while playing.
|
||||
LaunchedEffect(playing, points.size) {
|
||||
if (!playing || points.isEmpty()) return@LaunchedEffect
|
||||
while (playing && currentIndex < points.size - 1) {
|
||||
delay(200)
|
||||
currentIndex++
|
||||
}
|
||||
if (currentIndex >= points.size - 1) playing = false
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Top bar: back + title
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
"Trip #${trip.id}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
dateText(trip.start_datetime),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Map
|
||||
Box(Modifier.fillMaxWidth().height(300.dp)) {
|
||||
if (loading && points.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (points.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No route points", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
TripMapView(points = points, currentIndex = currentIndex, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
// Stats + scrubber
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
TripStatsHeader(route?.trip ?: TripMeta())
|
||||
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
if (points.isEmpty()) return@FilledIconButton
|
||||
if (currentIndex >= points.size - 1) currentIndex = 0
|
||||
playing = !playing
|
||||
},
|
||||
enabled = points.isNotEmpty(),
|
||||
) {
|
||||
Icon(
|
||||
if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = if (playing) "Pause" else "Play"
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
pointLabel(points, currentIndex),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TripStatsHeader(meta: TripMeta) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().padding(14.dp)) {
|
||||
HeaderStat("Distance", "%.1f km".format(Locale.US, meta.distance_km), Modifier.weight(1f))
|
||||
HeaderStat("Duration", durationText(meta.duration_seconds), Modifier.weight(1f))
|
||||
HeaderStat("Avg", "%.0f km/h".format(Locale.US, meta.average_speed_kmh), Modifier.weight(1f))
|
||||
HeaderStat("Max", "%.0f km/h".format(Locale.US, meta.max_speed_kmh), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderStat(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pointLabel(points: List<RoutePoint>, index: Int): String {
|
||||
if (points.isEmpty()) return "—"
|
||||
val p = points[index.coerceIn(0, points.size - 1)]
|
||||
val t = if (p.datetime.length >= 19) p.datetime.substring(11, 19) else p.datetime
|
||||
return "$t · ${"%.0f".format(Locale.US, p.speed)} km/h"
|
||||
}
|
||||
|
||||
private fun dateText(dt: String): String = if (dt.length >= 10) dt.substring(0, 10) else dt
|
||||
private fun durationText(seconds: Int): String {
|
||||
if (seconds < 60) return "${seconds}s"
|
||||
val h = seconds / 3600
|
||||
val m = (seconds % 3600) / 60
|
||||
return if (h > 0) "${h}h ${m}m" else "${m}m"
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.yarisobd.app.data.remote.PiApi
|
||||
import com.yarisobd.app.data.remote.dto.GeocodeBatchRequest
|
||||
import com.yarisobd.app.data.remote.dto.GeocodeCoord
|
||||
import com.yarisobd.app.data.remote.dto.LatLon
|
||||
import com.yarisobd.app.data.remote.dto.TripRoute
|
||||
import com.yarisobd.app.data.remote.dto.TripSummary
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Geocoded start/end labels for one trip. */
|
||||
data class TripLabels(val start: String, val end: String)
|
||||
|
||||
class TripViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val baseUrl = "http://10.42.0.1"
|
||||
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
|
||||
private val okHttp = OkHttpClient.Builder()
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val api: PiApi = Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
.client(okHttp)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(PiApi::class.java)
|
||||
|
||||
private val _trips = MutableStateFlow<List<TripSummary>>(emptyList())
|
||||
val trips: StateFlow<List<TripSummary>> = _trips.asStateFlow()
|
||||
|
||||
private val _tripLabels = MutableStateFlow<Map<Int, TripLabels>>(emptyMap())
|
||||
val tripLabels: StateFlow<Map<Int, TripLabels>> = _tripLabels.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
private val _route = MutableStateFlow<TripRoute?>(null)
|
||||
val route: StateFlow<TripRoute?> = _route.asStateFlow()
|
||||
|
||||
private val _routeLoading = MutableStateFlow(false)
|
||||
val routeLoading: StateFlow<Boolean> = _routeLoading.asStateFlow()
|
||||
|
||||
init {
|
||||
loadTrips()
|
||||
}
|
||||
|
||||
fun loadTrips() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
val resp = api.getTrips()
|
||||
_trips.value = resp.trips
|
||||
geocodeTripEndpoints(resp.trips)
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Failed to load trips: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRoute(tripId: Int) {
|
||||
viewModelScope.launch {
|
||||
_routeLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
_route.value = api.getTripRoute(tripId)
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Failed to load route: ${e.message}"
|
||||
} finally {
|
||||
_routeLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearRoute() {
|
||||
_route.value = null
|
||||
}
|
||||
|
||||
/** Best-effort: geocode every unique start/end coordinate in one batch call. */
|
||||
private suspend fun geocodeTripEndpoints(trips: List<TripSummary>) {
|
||||
val coords = mutableListOf<GeocodeCoord>()
|
||||
for (t in trips) {
|
||||
t.start?.takeIf { it.latitude != null && it.longitude != null }?.let {
|
||||
coords += GeocodeCoord(it.latitude!!, it.longitude!!)
|
||||
}
|
||||
t.end?.takeIf { it.latitude != null && it.longitude != null }?.let {
|
||||
coords += GeocodeCoord(it.latitude!!, it.longitude!!)
|
||||
}
|
||||
}
|
||||
if (coords.isEmpty()) return
|
||||
try {
|
||||
val result = api.geocodeBatch(GeocodeBatchRequest(coords))
|
||||
val labels = mutableMapOf<Int, TripLabels>()
|
||||
for (t in trips) {
|
||||
labels[t.id] = TripLabels(labelFor(result, t.start), labelFor(result, t.end))
|
||||
}
|
||||
_tripLabels.value = labels
|
||||
} catch (_: Exception) {
|
||||
// Geocoding is best-effort — the list still renders with raw coords.
|
||||
}
|
||||
}
|
||||
|
||||
private fun labelFor(result: Map<String, String>, ll: LatLon?): String {
|
||||
val lat = ll?.latitude ?: return "—"
|
||||
val lon = ll?.longitude ?: return "—"
|
||||
val key = String.format(Locale.US, "%.3f,%.3f", lat, lon)
|
||||
return result[key] ?: String.format(Locale.US, "%.4f, %.4f", lat, lon)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import android.app.Application
|
||||
import org.maplibre.android.MapLibre
|
||||
|
||||
class YarisApp : Application() {
|
||||
|
||||
@@ -8,6 +9,7 @@ class YarisApp : Application() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
DownloadWorker.createChannel(this)
|
||||
MapLibre.getInstance(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.yarisobd.app.data.remote
|
||||
|
||||
import com.yarisobd.app.data.remote.dto.DashcamFilesResponse
|
||||
import com.yarisobd.app.data.remote.dto.GeocodeBatchRequest
|
||||
import com.yarisobd.app.data.remote.dto.GeocodeResult
|
||||
import com.yarisobd.app.data.remote.dto.GpsStatus
|
||||
import com.yarisobd.app.data.remote.dto.ObdStatus
|
||||
import com.yarisobd.app.data.remote.dto.Stats
|
||||
import com.yarisobd.app.data.remote.dto.TripRoute
|
||||
import com.yarisobd.app.data.remote.dto.TripsResponse
|
||||
import com.yarisobd.app.data.remote.dto.VideoListResponse
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import okhttp3.ResponseBody
|
||||
@@ -11,6 +15,7 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Streaming
|
||||
|
||||
@@ -31,6 +36,22 @@ interface PiApi {
|
||||
@GET("/api/stats")
|
||||
suspend fun getStats(): Stats
|
||||
|
||||
/** Trip list (Phase 18) — newest first */
|
||||
@GET("/api/trips")
|
||||
suspend fun getTrips(): TripsResponse
|
||||
|
||||
/** Trip route points + metadata (Phase 18) */
|
||||
@GET("/api/trip/{id}")
|
||||
suspend fun getTripRoute(@Path("id") id: Int): TripRoute
|
||||
|
||||
/** Reverse-geocode a single coordinate (offline on the Pi) */
|
||||
@GET("/api/geocode")
|
||||
suspend fun geocode(@Query("lat") lat: Double, @Query("lon") lon: Double): GeocodeResult
|
||||
|
||||
/** Batch reverse-geocode — returns { "lat,lon": "label", ... } */
|
||||
@POST("/api/geocode/batch")
|
||||
suspend fun geocodeBatch(@Body body: GeocodeBatchRequest): Map<String, String>
|
||||
|
||||
/** List imported dashcam videos (Phase 17) */
|
||||
@GET("/api/local-videos")
|
||||
suspend fun listVideos(): VideoListResponse
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yarisobd.app.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Response from Pi's GET /api/geocode (offline reverse geocoder).
|
||||
*/
|
||||
@Serializable
|
||||
data class GeocodeResult(
|
||||
val label: String = "",
|
||||
)
|
||||
|
||||
/** Request body for Pi's POST /api/geocode/batch. */
|
||||
@Serializable
|
||||
data class GeocodeBatchRequest(
|
||||
val coords: List<GeocodeCoord> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GeocodeCoord(
|
||||
val lat: Double = 0.0,
|
||||
val lon: Double = 0.0,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yarisobd.app.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Response from Pi's GET /api/trip/{id}: trip metadata + route points.
|
||||
*/
|
||||
@Serializable
|
||||
data class TripRoute(
|
||||
val trip: TripMeta = TripMeta(),
|
||||
val points: List<RoutePoint> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TripMeta(
|
||||
val id: Int = 0,
|
||||
val start_datetime: String = "",
|
||||
val end_datetime: String = "",
|
||||
val distance_km: Double = 0.0,
|
||||
val duration_seconds: Int = 0,
|
||||
val point_count: Int = 0,
|
||||
val average_speed_kmh: Double = 0.0,
|
||||
val max_speed_kmh: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RoutePoint(
|
||||
val id: Int = 0,
|
||||
val datetime: String = "",
|
||||
val latitude: Double = 0.0,
|
||||
val longitude: Double = 0.0,
|
||||
val speed: Double = 0.0,
|
||||
val speed_source: String = "",
|
||||
val odometer: Double? = null,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.yarisobd.app.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One trip from the Pi's GET /api/trips (Phase 18). Newest first.
|
||||
*/
|
||||
@Serializable
|
||||
data class TripSummary(
|
||||
val id: Int = 0,
|
||||
val start_datetime: String = "",
|
||||
val end_datetime: String = "",
|
||||
val start: LatLon? = null,
|
||||
val end: LatLon? = null,
|
||||
val distance_km: Double = 0.0,
|
||||
val duration_seconds: Int = 0,
|
||||
val point_count: Int = 0,
|
||||
val average_speed_kmh: Double = 0.0,
|
||||
val max_speed_kmh: Double = 0.0,
|
||||
val odo_start_km: Double? = null,
|
||||
val odo_end_km: Double? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LatLon(
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TripsResponse(
|
||||
val trips: List<TripSummary> = emptyList(),
|
||||
)
|
||||
Reference in New Issue
Block a user