diff --git a/PLAN.md b/PLAN.md index b72f4ba..c844332 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e5999e0..f32d0e3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt index 5e5512d..ce587eb 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -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(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() }, + ) } } } + } } diff --git a/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt b/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt new file mode 100644 index 0000000..6c75165 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt @@ -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, + labels: Map, + 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) +} diff --git a/android/app/src/main/java/com/yarisobd/app/TripMapView.kt b/android/app/src/main/java/com/yarisobd/app/TripMapView.kt new file mode 100644 index 0000000..004e1be --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/TripMapView.kt @@ -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, currentIndex: Int, modifier: Modifier = Modifier) { + val context = LocalContext.current + val mapView = remember { MapView(context) } + val mapRef = remember { mutableStateOf(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("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) { + val lngLats = points.map { Point.fromLngLat(it.longitude, it.latitude) } + val line = LineString.fromLngLats(lngLats) + val feature = Feature.fromGeometry(line) + map.style?.getSourceAs("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) +} diff --git a/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt b/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt new file mode 100644 index 0000000..f51e7e7 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt @@ -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, 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" +} diff --git a/android/app/src/main/java/com/yarisobd/app/TripViewModel.kt b/android/app/src/main/java/com/yarisobd/app/TripViewModel.kt new file mode 100644 index 0000000..568f4b7 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/TripViewModel.kt @@ -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>(emptyList()) + val trips: StateFlow> = _trips.asStateFlow() + + private val _tripLabels = MutableStateFlow>(emptyMap()) + val tripLabels: StateFlow> = _tripLabels.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _route = MutableStateFlow(null) + val route: StateFlow = _route.asStateFlow() + + private val _routeLoading = MutableStateFlow(false) + val routeLoading: StateFlow = _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) { + val coords = mutableListOf() + 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() + 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, 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) + } +} diff --git a/android/app/src/main/java/com/yarisobd/app/YarisApp.kt b/android/app/src/main/java/com/yarisobd/app/YarisApp.kt index a4f7828..2dc9df5 100644 --- a/android/app/src/main/java/com/yarisobd/app/YarisApp.kt +++ b/android/app/src/main/java/com/yarisobd/app/YarisApp.kt @@ -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 { diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt index 399ebb8..61196a6 100644 --- a/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt @@ -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 + /** List imported dashcam videos (Phase 17) */ @GET("/api/local-videos") suspend fun listVideos(): VideoListResponse diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/dto/Geocode.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/Geocode.kt new file mode 100644 index 0000000..a629ed2 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/Geocode.kt @@ -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 = emptyList(), +) + +@Serializable +data class GeocodeCoord( + val lat: Double = 0.0, + val lon: Double = 0.0, +) diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/dto/TripRoute.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/TripRoute.kt new file mode 100644 index 0000000..c66af04 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/TripRoute.kt @@ -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 = 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, +) diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/dto/TripSummary.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/TripSummary.kt new file mode 100644 index 0000000..9bdd36f --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/TripSummary.kt @@ -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 = emptyList(), +) diff --git a/android/plan.md b/android/plan.md index ad7b9ed..25541db 100644 --- a/android/plan.md +++ b/android/plan.md @@ -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` - - `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` (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.**