feat(android): trip history list + route replay map (Phase 28.5)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,6 +194,18 @@ 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
@@ -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.**
|
||||||
|
|||||||
Reference in New Issue
Block a user