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)
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 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).**
@@ -2203,12 +2203,14 @@ The app reuses the existing Pi REST API surface with no changes:
connection status bar (Pi WiFi + Server dots).
- GPS map (MapLibre) deferred — basemap needs internet, which the Pi hotspot doesn't provide.
**28.5 — Trip history**
- List trips from `/api/trips` with date, distance, duration, start/end locations
- Tap to view route on map with scrubber timeline (reuse the same data as Phase 18 web
replay, just rendered natively)
- Geocode via the Pi's `/api/geocode` or the phone's own Android
`Geocoder` (no network needed for the Pi's offline `reverse_geocoder` path)
**28.5 — Trip history ✅ DONE (2026-08-15)**
- `TripViewModel` loads `/api/trips` (with batch geocode of start/end via `/api/geocode/batch`)
and `/api/trip/{id}` for the route.
- `TripListScreen` (new Trips tab): date/time, distance, duration, avg/max speed, start→end labels.
- `TripReplayScreen`: MapLibre map using the Pi's offline raster tiles (`/static/vendor/tiles/{z}/{x}/{y}.png`,
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)**
- 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.okhttp)
// Maps — will be used in 28.4
// implementation(libs.maplibre)
// Maps
implementation(libs.maplibre)
// Security
implementation(libs.security.crypto)
@@ -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,6 +194,18 @@ 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(),
)
+20 -20
View File
@@ -178,30 +178,30 @@ android/
- [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).
### 28.5 — Trip history ⬜ NOT STARTED
### 28.5 — Trip history ✅ DONE (2026-08-15)
**Depends on: 28.1. Independent of 28.2–28.4.**
#### 28.5.1 — Data layer
- [ ] Retrofit interface additions:
- `GET /api/trips` → `List<TripSummary>`
- `GET /api/trip/{id}` → `TripRoute` (list of route points with lat, lon, speed, timestamp)
- `GET /api/geocode?lat={}&lon={}` → `GeocodeResult` (label)
- [ ] Room cache: store trip list + last-viewed route for offline viewing
- [ ] Commit: `feat: trip history data layer`
#### 28.5.1 — Data layer ✅
- [x] Retrofit interface additions to `PiApi`:
- `GET /api/trips` → `TripsResponse` (list of `TripSummary`)
- `GET /api/trip/{id}` → `TripRoute` (metadata + route points with lat/lon/speed/source)
- `GET /api/geocode?lat&lon` → `GeocodeResult`
- `POST /api/geocode/batch` → `Map<String,String>` (one call for all start/end labels)
- [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
- [ ] LazyColumn of trips: date, start time, distance (km), duration, start→end locations
- [ ] Tap → navigates to route replay
- [ ] Sort: by date (default: newest first)
- [ ] Commit: `feat: trip list screen`
#### 28.5.2 — Trip list UI ✅
- [x] `LazyColumn` of trips: date, start→end time, distance, duration, avg/max speed, start→end labels
- [x] Tap → opens `TripReplayScreen`
- [x] Newest first (Pi returns newest-first)
#### 28.5.3 — Route replay UI
- [ ] MapLibre MapView with route polyline + start/end markers
- [ ] Timeline scrubber (slider) with timestamp + speed label
- [ ] Play/pause button: animates marker along route
- [ ] Geocoded location name below scrubber (debounced while scrubbing)
- [ ] Trip stats header: date, distance, duration, avg speed, max speed
- [ ] Commit: `feat: route replay screen`
#### 28.5.3 — Route replay UI ✅
- [x] MapLibre `MapView` with the Pi's offline raster tiles (`/static/vendor/tiles/{z}/{x}/{y}.png`, zoom 8–15) — works with no internet
- [x] Route polyline (GeoJsonSource + LineLayer) + cursor circle marker, camera fit to bounds
- [x] Timeline scrubber (Slider) with timestamp + speed label
- [x] Play/pause button animating the cursor along the route (~5 fps)
- [x] Trip stats header: distance, duration, avg speed, max speed
- [ ] Start/end markers + geocode label below scrubber — deferred
### 28.6 — Remote portal direct access ⬜ NOT STARTED
**Depends on: 28.1 (credentials). Independent of 28.2–28.5. Stretch goal.**