feat(android): trip history list + route replay map (Phase 28.5)

This commit is contained in:
Mathieu Moran
2026-08-15 19:51:06 -04:00
parent cadde5aeee
commit a40fced666
13 changed files with 816 additions and 29 deletions
+9 -7
View File
@@ -2021,7 +2021,7 @@ tracked in FEATURES.md.
2. **FEAT-02** — QR / claim-code device pairing flow (medium) 2. **FEAT-02** — QR / claim-code device pairing flow (medium)
3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large) 3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large)
### Phase 28: Native Android companion app — 🚧 IN PROGRESS (28.1 ✅, 28.2 ✅, 28.3 ✅, 28.4 ✅, 28.5–28.7 pending) ### Phase 28: Native Android companion app — 🚧 IN PROGRESS (28.1 ✅, 28.2 ✅, 28.3 ✅, 28.4 ✅, 28.5 ✅, 28.6–28.7 pending)
**Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase. **Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase.
Renumbered to Phase 28 on 2026-08-11 to make room for Phase 27 feature additions. Planning Renumbered to Phase 28 on 2026-08-11 to make room for Phase 27 feature additions. Planning
completed 2026-08-11. Detailed implementation plan in [android/plan.md](android/plan.md).** completed 2026-08-11. Detailed implementation plan in [android/plan.md](android/plan.md).**
@@ -2203,12 +2203,14 @@ The app reuses the existing Pi REST API surface with no changes:
connection status bar (Pi WiFi + Server dots). connection status bar (Pi WiFi + Server dots).
- GPS map (MapLibre) deferred — basemap needs internet, which the Pi hotspot doesn't provide. - GPS map (MapLibre) deferred — basemap needs internet, which the Pi hotspot doesn't provide.
**28.5 — Trip history** **28.5 — Trip history ✅ DONE (2026-08-15)**
- List trips from `/api/trips` with date, distance, duration, start/end locations - `TripViewModel` loads `/api/trips` (with batch geocode of start/end via `/api/geocode/batch`)
- Tap to view route on map with scrubber timeline (reuse the same data as Phase 18 web and `/api/trip/{id}` for the route.
replay, just rendered natively) - `TripListScreen` (new Trips tab): date/time, distance, duration, avg/max speed, start→end labels.
- Geocode via the Pi's `/api/geocode` or the phone's own Android - `TripReplayScreen`: MapLibre map using the Pi's offline raster tiles (`/static/vendor/tiles/{z}/{x}/{y}.png`,
`Geocoder` (no network needed for the Pi's offline `reverse_geocoder` path) zoom 8–15 — no internet needed), route polyline + cursor marker, camera fit, timeline scrubber
with timestamp + speed, and play/pause animation.
- Deferred: Room offline cache, start/end markers, geocode label under the scrubber.
**28.6 — Remote portal direct access (away from car)** **28.6 — Remote portal direct access (away from car)**
- Talk to `https://yaris.ceaf.dev/api/v1/portal/...` directly - Talk to `https://yaris.ceaf.dev/api/v1/portal/...` directly
+2 -2
View File
@@ -99,8 +99,8 @@ dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.okhttp) implementation(libs.coil.okhttp)
// Maps — will be used in 28.4 // Maps
// implementation(libs.maplibre) implementation(libs.maplibre)
// Security // Security
implementation(libs.security.crypto) implementation(libs.security.crypto)
@@ -19,6 +19,7 @@ import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.filled.Dashboard 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.FileDownload
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -26,11 +27,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.yarisobd.app.data.remote.dto.TripSummary
import com.yarisobd.app.ui.theme.YarisOBDTheme import com.yarisobd.app.ui.theme.YarisOBDTheme
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -60,6 +63,13 @@ fun YarisMainScreen(client: OkHttpClient) {
val piOnline by dashboardVm.piOnline.collectAsState() val piOnline by dashboardVm.piOnline.collectAsState()
val serverOnline by dashboardVm.serverOnline.collectAsState() val serverOnline by dashboardVm.serverOnline.collectAsState()
val lastUpdate by dashboardVm.lastUpdate.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 videos by dashcamVm.videos.collectAsState()
val totalSize by dashcamVm.totalSize.collectAsState() val totalSize by dashcamVm.totalSize.collectAsState()
val retentionDays by dashcamVm.retentionDays.collectAsState() val retentionDays by dashcamVm.retentionDays.collectAsState()
@@ -81,6 +91,7 @@ fun YarisMainScreen(client: OkHttpClient) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
var selectedTrip by remember { mutableStateOf<TripSummary?>(null) }
// Show download/delete events as snackbars // Show download/delete events as snackbars
LaunchedEffect(Unit) { 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( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
@@ -114,6 +136,12 @@ fun YarisMainScreen(client: OkHttpClient) {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 3, selected = selectedTab == 3,
onClick = { 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") }, icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
label = { Text("Settings") } label = { Text("Settings") }
) )
@@ -166,7 +194,19 @@ fun YarisMainScreen(client: OkHttpClient) {
onRefresh = { dashcamVm.scanDownloads() }, onRefresh = { dashcamVm.scanDownloads() },
) )
3 -> SettingsScreen(client = client) 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 package com.yarisobd.app
import android.app.Application import android.app.Application
import org.maplibre.android.MapLibre
class YarisApp : Application() { class YarisApp : Application() {
@@ -8,6 +9,7 @@ class YarisApp : Application() {
super.onCreate() super.onCreate()
instance = this instance = this
DownloadWorker.createChannel(this) DownloadWorker.createChannel(this)
MapLibre.getInstance(this)
} }
companion object { companion object {
@@ -1,9 +1,13 @@
package com.yarisobd.app.data.remote package com.yarisobd.app.data.remote
import com.yarisobd.app.data.remote.dto.DashcamFilesResponse 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.GpsStatus
import com.yarisobd.app.data.remote.dto.ObdStatus import com.yarisobd.app.data.remote.dto.ObdStatus
import com.yarisobd.app.data.remote.dto.Stats 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 com.yarisobd.app.data.remote.dto.VideoListResponse
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import okhttp3.ResponseBody import okhttp3.ResponseBody
@@ -11,6 +15,7 @@ import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.HTTP import retrofit2.http.HTTP
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Streaming import retrofit2.http.Streaming
@@ -31,6 +36,22 @@ interface PiApi {
@GET("/api/stats") @GET("/api/stats")
suspend fun getStats(): 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) */ /** List imported dashcam videos (Phase 17) */
@GET("/api/local-videos") @GET("/api/local-videos")
suspend fun listVideos(): VideoListResponse 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(),
)
+20 -20
View File
@@ -178,30 +178,30 @@ android/
- [x] Auto-refresh every 2s; polling stops when navigating away from the tab - [x] Auto-refresh every 2s; polling stops when navigating away from the tab
- [ ] GPS map (MapLibre) — deferred: needs the MapLibre dep + basemap over hotspot (no internet). - [ ] GPS map (MapLibre) — deferred: needs the MapLibre dep + basemap over hotspot (no internet).
### 28.5 — Trip history ⬜ NOT STARTED ### 28.5 — Trip history ✅ DONE (2026-08-15)
**Depends on: 28.1. Independent of 28.2–28.4.** **Depends on: 28.1. Independent of 28.2–28.4.**
#### 28.5.1 — Data layer #### 28.5.1 — Data layer ✅
- [ ] Retrofit interface additions: - [x] Retrofit interface additions to `PiApi`:
- `GET /api/trips` → `List<TripSummary>` - `GET /api/trips` → `TripsResponse` (list of `TripSummary`)
- `GET /api/trip/{id}` → `TripRoute` (list of route points with lat, lon, speed, timestamp) - `GET /api/trip/{id}` → `TripRoute` (metadata + route points with lat/lon/speed/source)
- `GET /api/geocode?lat={}&lon={}` → `GeocodeResult` (label) - `GET /api/geocode?lat&lon` → `GeocodeResult`
- [ ] Room cache: store trip list + last-viewed route for offline viewing - `POST /api/geocode/batch` → `Map<String,String>` (one call for all start/end labels)
- [ ] Commit: `feat: trip history data layer` - [x] `TripViewModel`: `loadTrips()` (with batch geocode of start/end), `loadRoute(id)`, `clearRoute()`
- [ ] Room cache (trip list + last route for offline) — deferred; Pi is the live source
#### 28.5.2 — Trip list UI #### 28.5.2 — Trip list UI ✅
- [ ] LazyColumn of trips: date, start time, distance (km), duration, start→end locations - [x] `LazyColumn` of trips: date, start→end time, distance, duration, avg/max speed, start→end labels
- [ ] Tap → navigates to route replay - [x] Tap → opens `TripReplayScreen`
- [ ] Sort: by date (default: newest first) - [x] Newest first (Pi returns newest-first)
- [ ] Commit: `feat: trip list screen`
#### 28.5.3 — Route replay UI #### 28.5.3 — Route replay UI ✅
- [ ] MapLibre MapView with route polyline + start/end markers - [x] MapLibre `MapView` with the Pi's offline raster tiles (`/static/vendor/tiles/{z}/{x}/{y}.png`, zoom 8–15) — works with no internet
- [ ] Timeline scrubber (slider) with timestamp + speed label - [x] Route polyline (GeoJsonSource + LineLayer) + cursor circle marker, camera fit to bounds
- [ ] Play/pause button: animates marker along route - [x] Timeline scrubber (Slider) with timestamp + speed label
- [ ] Geocoded location name below scrubber (debounced while scrubbing) - [x] Play/pause button animating the cursor along the route (~5 fps)
- [ ] Trip stats header: date, distance, duration, avg speed, max speed - [x] Trip stats header: distance, duration, avg speed, max speed
- [ ] Commit: `feat: route replay screen` - [ ] Start/end markers + geocode label below scrubber — deferred
### 28.6 — Remote portal direct access ⬜ NOT STARTED ### 28.6 — Remote portal direct access ⬜ NOT STARTED
**Depends on: 28.1 (credentials). Independent of 28.2–28.5. Stretch goal.** **Depends on: 28.1 (credentials). Independent of 28.2–28.5. Stretch goal.**