From cadde5aeeeea4e15ce1f933df66b0ceaa79f4a72 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sat, 15 Aug 2026 19:40:47 -0400 Subject: [PATCH] feat(android): live dashboard screen (Phase 28.4) --- PLAN.md | 14 +- .../java/com/yarisobd/app/DashboardScreen.kt | 305 ++++++++++++++++++ .../com/yarisobd/app/DashboardViewModel.kt | 128 ++++++++ .../java/com/yarisobd/app/MainActivity.kt | 38 ++- .../com/yarisobd/app/data/remote/PiApi.kt | 10 + .../yarisobd/app/data/remote/dto/ObdStatus.kt | 23 ++ .../com/yarisobd/app/data/remote/dto/Stats.kt | 18 ++ android/plan.md | 36 ++- 8 files changed, 542 insertions(+), 30 deletions(-) create mode 100644 android/app/src/main/java/com/yarisobd/app/DashboardScreen.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/DashboardViewModel.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/data/remote/dto/ObdStatus.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/data/remote/dto/Stats.kt diff --git a/PLAN.md b/PLAN.md index 3e6bb50..b72f4ba 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.7 pending) +### Phase 28: Native Android companion app — 🚧 IN PROGRESS (28.1 ✅, 28.2 ✅, 28.3 ✅, 28.4 ✅, 28.5–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).** @@ -2195,11 +2195,13 @@ The app reuses the existing Pi REST API surface with no changes: a new lightweight endpoint or the app skips this and the Pi's own uploader just sees the file is already on the server next time it scans.) -**28.4 — Live dashboard** -- GPS position on a MapLibre map (updates every 2s from `/api/gps/status`) -- Speed, RPM, coolant temp, fuel level, throttle cards (from `/api/obd/status` + - socket.io — or polling `/api/stats` every few seconds) -- MIL/DTC indicator (from `/api/dtc`) +**28.4 — Live dashboard ✅ DONE (2026-08-15)** +- `DashboardViewModel` polls `GET /api/gps/status` + `GET /api/obd/status` + `GET /api/stats` + every 2s (start/stop on tab focus), plus a server `/health` check for the cellular dot. +- `DashboardScreen` (new first tab): big speed + RPM cards (RPM color scale), engine stats row + (coolant/fuel/throttle), MIL/DTC card, GPS card (fix/lat/lon/age), overview card, and a + 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 diff --git a/android/app/src/main/java/com/yarisobd/app/DashboardScreen.kt b/android/app/src/main/java/com/yarisobd/app/DashboardScreen.kt new file mode 100644 index 0000000..74366ae --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/DashboardScreen.kt @@ -0,0 +1,305 @@ +package com.yarisobd.app + +import androidx.compose.foundation.background +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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 java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val MIL_AMBER = Color(0xFFE6A817) + +@Composable +fun DashboardScreen( + gps: GpsStatus?, + obd: ObdStatus?, + stats: Stats?, + piOnline: Boolean, + serverOnline: Boolean, + lastUpdate: Long, + onStartPolling: () -> Unit, + onStopPolling: () -> Unit, +) { + // Poll while this screen is composed; stop when the user navigates away. + DisposableEffect(Unit) { + onStartPolling() + onDispose { onStopPolling() } + } + + Column(modifier = Modifier.fillMaxSize()) { + com.yarisobd.app.ui.components.YarisHeader() + + // Connection status bar + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatusDot(online = piOnline, label = "Pi WiFi", color = MaterialTheme.colorScheme.primary) + StatusDot(online = serverOnline, label = "Server", color = MaterialTheme.colorScheme.tertiary) + Spacer(Modifier.weight(1f)) + if (lastUpdate > 0L) { + Text( + "Updated ${SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(lastUpdate))}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + SpeedCard(obd, Modifier.weight(1f)) + RpmCard(obd, Modifier.weight(1f)) + } + + EngineStatsCard(obd) + MilCard(obd, stats) + GpsCard(gps) + OverviewCard(stats) + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun StatusDot(online: Boolean, label: String, color: Color) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(10.dp) + .background(if (online) color else MaterialTheme.colorScheme.surfaceVariant, CircleShape) + ) + Spacer(Modifier.width(6.dp)) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = if (online) color else MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun SpeedCard(obd: ObdStatus?, modifier: Modifier = Modifier) { + val speed = obd?.speed + val connected = obd?.car_connected == true + Card( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Filled.Speed, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp)) + Spacer(Modifier.height(6.dp)) + Text(speedText(speed), fontSize = 44.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) + Text("km/h", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Text( + if (connected) "OBD speed" else "OBD offline", + style = MaterialTheme.typography.bodySmall, + color = if (connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + ) + } + } +} + +@Composable +private fun RpmCard(obd: ObdStatus?, modifier: Modifier = Modifier) { + val rpm = obd?.rpm + Card( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("RPM", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(6.dp)) + Text(intText(rpm), fontSize = 44.sp, fontWeight = FontWeight.Bold, color = rpmColor(rpm)) + Text("rev/min", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun EngineStatsCard(obd: ObdStatus?) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Row(Modifier.fillMaxWidth().padding(16.dp)) { + StatCell("Coolant", "${intText(obd?.coolant_temp)} °C", Modifier.weight(1f)) + StatCell("Fuel", "${intText(obd?.fuel_level)} %", Modifier.weight(1f)) + StatCell("Throttle", "${intText(obd?.throttle_position)} %", Modifier.weight(1f)) + } + } +} + +@Composable +private fun StatCell(label: String, value: String, modifier: Modifier = Modifier) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun MilCard(obd: ObdStatus?, stats: Stats?) { + val mil = obd?.mil_on + val dtcCount = stats?.active_dtc ?: 0 + val color = when { + mil == true -> MIL_AMBER + mil == false -> Color(0xFF2E7D32) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + val label = when { + mil == true -> "MIL ON — Check Engine" + mil == false -> "No MIL" + else -> "MIL unknown" + } + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Warning, null, tint = color, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleSmall, color = color, fontWeight = FontWeight.SemiBold) + Text("DTCs stored: $dtcCount", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text("DTC", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun GpsCard(gps: GpsStatus?) { + val hasFix = gps?.has_fix == true + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.LocationOn, + null, + tint = if (hasFix) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + if (hasFix) "GPS fix" else "No GPS fix", + style = MaterialTheme.typography.titleSmall, + color = if (hasFix) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.weight(1f)) + Text( + "age ${ageText(gps?.fix_age_seconds)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth()) { + StatCell("Lat", coordText(gps?.latitude), Modifier.weight(1f)) + StatCell("Lon", coordText(gps?.longitude), Modifier.weight(1f)) + StatCell("Thread", if (gps?.thread_alive == true) "alive" else "—", Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun OverviewCard(stats: Stats?) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Text("Overview", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(10.dp)) + Row(Modifier.fillMaxWidth()) { + StatCell("Trips", "${stats?.trip_count ?: 0}", Modifier.weight(1f)) + StatCell("Total km", oneDecimal(stats?.total_km), Modifier.weight(1f)) + StatCell("Max speed", "${intText(stats?.all_time_max_speed)} km/h", Modifier.weight(1f)) + } + Spacer(Modifier.height(10.dp)) + Row(Modifier.fillMaxWidth()) { + StatCell("Videos", "${stats?.video_count ?: 0}", Modifier.weight(1f)) + StatCell("DTCs", "${stats?.active_dtc ?: 0}", Modifier.weight(1f)) + StatCell("Odometer", odoText(stats), Modifier.weight(1f)) + } + } + } +} + +// --- formatting helpers --- + +private fun speedText(v: Double?): String = v?.let { "%.0f".format(it) } ?: "—" +private fun intText(v: Double?): String = v?.let { "%.0f".format(it) } ?: "—" +private fun oneDecimal(v: Double?): String = v?.let { "%.1f".format(it) } ?: "—" +private fun coordText(v: Double?): String = v?.let { "%.4f".format(it) } ?: "—" +private fun ageText(v: Double?): String = v?.let { "%.0fs".format(it) } ?: "—" + +private fun odoText(stats: Stats?): String { + val v = stats?.odometer_km ?: return "—" + return "%.0f".format(v) +} + +@Composable +private fun rpmColor(rpm: Double?): Color = when { + rpm == null -> MaterialTheme.colorScheme.onSurface + rpm < 3000 -> Color(0xFF2E7D32) + rpm < 5000 -> MIL_AMBER + else -> MaterialTheme.colorScheme.error +} diff --git a/android/app/src/main/java/com/yarisobd/app/DashboardViewModel.kt b/android/app/src/main/java/com/yarisobd/app/DashboardViewModel.kt new file mode 100644 index 0000000..e98156c --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/DashboardViewModel.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.CredentialStore +import com.yarisobd.app.data.remote.PiApi +import com.yarisobd.app.data.remote.ServerApi +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 kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +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.concurrent.TimeUnit + +class DashboardViewModel(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 _gps = MutableStateFlow(null) + val gps: StateFlow = _gps.asStateFlow() + + private val _obd = MutableStateFlow(null) + val obd: StateFlow = _obd.asStateFlow() + + private val _stats = MutableStateFlow(null) + val stats: StateFlow = _stats.asStateFlow() + + private val _piOnline = MutableStateFlow(false) + val piOnline: StateFlow = _piOnline.asStateFlow() + + private val _serverOnline = MutableStateFlow(false) + val serverOnline: StateFlow = _serverOnline.asStateFlow() + + private val _lastUpdate = MutableStateFlow(0L) + val lastUpdate: StateFlow = _lastUpdate.asStateFlow() + + private var pollJob: Job? = null + private var serverApi: ServerApi? = null + + /** Begin the 2s poll loop. Fetches immediately, then every 2s. */ + fun startPolling() { + if (pollJob?.isActive == true) return + pollJob = viewModelScope.launch { + while (isActive) { + fetch() + delay(2000) + } + } + } + + fun stopPolling() { + pollJob?.cancel() + pollJob = null + } + + fun refreshNow() { + viewModelScope.launch { fetch() } + } + + private suspend fun fetch() { + // Pi (hotspot WiFi) — GPS + OBD + overview stats, all on 10.42.0.1 + try { + val g = api.getGpsStatus() + val o = api.getObdStatus() + val s = api.getStats() + _gps.value = g + _obd.value = o + _stats.value = s + _piOnline.value = true + _lastUpdate.value = System.currentTimeMillis() + } catch (e: Exception) { + _piOnline.value = false + } + + // Server (cellular) — lightweight /health check when credentials are set + val srv = serverApi() + if (srv != null) { + try { + srv.health() + _serverOnline.value = true + } catch (e: Exception) { + _serverOnline.value = false + } + } else { + _serverOnline.value = false + } + } + + /** Lazily build the ServerApi from the stored server URL (built once). */ + private fun serverApi(): ServerApi? { + val ctx = getApplication().applicationContext + val serverUrl = CredentialStore.getServerUrl(ctx) + if (!CredentialStore.isConfigured(ctx) || serverUrl.isBlank()) return null + serverApi?.let { return it } + val built = Retrofit.Builder() + .baseUrl(serverUrl.trimEnd('/') + "/") + .client(okHttp) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(ServerApi::class.java) + serverApi = built + return built + } +} 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 d70ad58..5e5512d 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHostState 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.FileDownload import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable @@ -52,6 +53,13 @@ class MainActivity : ComponentActivity() { @Composable fun YarisMainScreen(client: OkHttpClient) { val dashcamVm: DashcamViewModel = viewModel() + val dashboardVm: DashboardViewModel = viewModel() + val gps by dashboardVm.gps.collectAsState() + val obd by dashboardVm.obd.collectAsState() + val stats by dashboardVm.stats.collectAsState() + val piOnline by dashboardVm.piOnline.collectAsState() + val serverOnline by dashboardVm.serverOnline.collectAsState() + val lastUpdate by dashboardVm.lastUpdate.collectAsState() val videos by dashcamVm.videos.collectAsState() val totalSize by dashcamVm.totalSize.collectAsState() val retentionDays by dashcamVm.retentionDays.collectAsState() @@ -88,18 +96,24 @@ fun YarisMainScreen(client: OkHttpClient) { NavigationBarItem( selected = selectedTab == 0, onClick = { selectedTab = 0 }, - icon = { Icon(Icons.Filled.Videocam, contentDescription = "Dashcam") }, - label = { Text("Dashcam") } + icon = { Icon(Icons.Filled.Dashboard, contentDescription = "Dashboard") }, + label = { Text("Dashboard") } ) NavigationBarItem( selected = selectedTab == 1, onClick = { selectedTab = 1 }, - icon = { Icon(Icons.Filled.FileDownload, contentDescription = "Downloads") }, - label = { Text("Downloads") } + icon = { Icon(Icons.Filled.Videocam, contentDescription = "Dashcam") }, + label = { Text("Dashcam") } ) NavigationBarItem( selected = selectedTab == 2, onClick = { selectedTab = 2 }, + icon = { Icon(Icons.Filled.FileDownload, contentDescription = "Downloads") }, + label = { Text("Downloads") } + ) + NavigationBarItem( + selected = selectedTab == 3, + onClick = { selectedTab = 3 }, icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") }, label = { Text("Settings") } ) @@ -108,7 +122,17 @@ fun YarisMainScreen(client: OkHttpClient) { ) { padding -> Column(modifier = Modifier.padding(padding)) { when (selectedTab) { - 0 -> DashcamScreen( + 0 -> DashboardScreen( + gps = gps, + obd = obd, + stats = stats, + piOnline = piOnline, + serverOnline = serverOnline, + lastUpdate = lastUpdate, + onStartPolling = { dashboardVm.startPolling() }, + onStopPolling = { dashboardVm.stopPolling() }, + ) + 1 -> DashcamScreen( videos = videos, totalSize = totalSize, retentionDays = retentionDays, @@ -125,7 +149,7 @@ fun YarisMainScreen(client: OkHttpClient) { onDeleteDashcam = { dashcamVm.deleteDashcamFile(it) }, onImport = { dashcamVm.importDashcamFiles(it) }, ) - 1 -> DownloadsScreen( + 2 -> DownloadsScreen( files = downloadedFiles, activeDownloads = activeDownloads, activeUploads = uploadProgress, @@ -141,7 +165,7 @@ fun YarisMainScreen(client: OkHttpClient) { onGenerateTestFile = { dashcamVm.generateTestFile() }, onRefresh = { dashcamVm.scanDownloads() }, ) - 2 -> SettingsScreen(client = client) + 3 -> SettingsScreen(client = client) } } } 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 ece5ab6..399ebb8 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 @@ -2,6 +2,8 @@ package com.yarisobd.app.data.remote import com.yarisobd.app.data.remote.dto.DashcamFilesResponse 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.VideoListResponse import kotlinx.serialization.json.JsonObject import okhttp3.ResponseBody @@ -21,6 +23,14 @@ interface PiApi { @GET("/api/gps/status") suspend fun getGpsStatus(): GpsStatus + /** Live OBD values (speed, rpm, coolant, fuel, throttle, MIL) */ + @GET("/api/obd/status") + suspend fun getObdStatus(): ObdStatus + + /** Overview stats (trips, odometer, DTC count, video count) */ + @GET("/api/stats") + suspend fun getStats(): Stats + /** 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/ObdStatus.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/ObdStatus.kt new file mode 100644 index 0000000..1c27738 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/ObdStatus.kt @@ -0,0 +1,23 @@ +package com.yarisobd.app.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Response from Pi's GET /api/obd/status. + * + * Live OBD values — numeric fields are null while the Pi hasn't received a + * fresh value from the car yet. + */ +@Serializable +data class ObdStatus( + val adapter_present: Boolean = false, + val port: String? = null, + val car_connected: Boolean = false, + val status_label: String = "", + val speed: Double? = null, + val rpm: Double? = null, + val coolant_temp: Double? = null, + val fuel_level: Double? = null, + val throttle_position: Double? = null, + val mil_on: Boolean? = null, +) diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/dto/Stats.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/Stats.kt new file mode 100644 index 0000000..7ea7f2a --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/Stats.kt @@ -0,0 +1,18 @@ +package com.yarisobd.app.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Response from Pi's GET /api/stats (Phase 23 overview stats). + */ +@Serializable +data class Stats( + val trip_count: Int = 0, + val total_km: Double = 0.0, + val total_duration_s: Double = 0.0, + val all_time_max_speed: Double = 0.0, + val active_dtc: Int = 0, + val video_count: Int = 0, + val odometer_km: Double? = null, + val odometer_source: String = "", +) diff --git a/android/plan.md b/android/plan.md index 082a91b..ad7b9ed 100644 --- a/android/plan.md +++ b/android/plan.md @@ -154,27 +154,29 @@ android/ - [x] Retry button per failed file (tracks failed uploads via `failedUploads` StateFlow) - [x] Commit: `feat: upload queue screen — upload all + per-file retry` -### 28.4 — Live dashboard ⬜ NOT STARTED +### 28.4 — Live dashboard ✅ DONE (2026-08-15) — MapLibre map deferred **Depends on: 28.1 (networking layer). Independent of 28.2/28.3.** -#### 28.4.1 — Data layer -- [ ] Retrofit interface additions to `PiApi`: +#### 28.4.1 — Data layer ✅ +- [x] Retrofit interface additions to `PiApi`: - `GET /api/gps/status` → `GpsStatus` (has_fix, lat, lon, fix_age_seconds, thread_alive) - - `GET /api/obd/status` → `ObdStatus` (adapter_present, port, car_connected) - - `GET /api/stats` → `Stats` (speed, rpm, coolant_temp, fuel_level, throttle_pos, mil_on, dtc_count, odometer_km) -- [ ] Polling scheduler: coroutine `while(true) { delay(2000); fetch() }` scoped to ViewModel -- [ ] Commit: `feat: dashboard data polling` + - `GET /api/obd/status` → `ObdStatus` (adapter_present, port, car_connected, speed, rpm, + coolant_temp, fuel_level, throttle_position, mil_on) + - `GET /api/stats` → `Stats` (trip_count, total_km, active_dtc, video_count, odometer_km) +- [x] Polling scheduler: `DashboardViewModel` with `startPolling()`/`stopPolling()` — fetches + immediately then every 2s; stopped via `DisposableEffect` when the tab loses focus. +- [x] Server `/health` check (cellular) alongside Pi polling for the connection bar. -#### 28.4.2 — Dashboard UI -- [ ] GPS card: MapLibre MapView centered on last fix with car marker -- [ ] Speed card: large number (km/h), OBD or GPS source label -- [ ] RPM card: gauge or number with color scale (green → yellow → red) -- [ ] Engine stats row: coolant temp (°C), fuel level (%), throttle (%) -- [ ] MIL/DTC indicator: check-engine icon (green/grey = no MIL, yellow/red = MIL on) - with DTC code count badge -- [ ] Connection status bar: Pi WiFi icon (green = connected), cellular icon (green = online) -- [ ] Auto-refresh every 2s, pause when app is backgrounded -- [ ] Commit: `feat: live dashboard screen` +#### 28.4.2 — Dashboard UI ✅ (map deferred) +- [x] Speed card: large number (km/h), "OBD speed"/"OBD offline" source label +- [x] RPM card: number with color scale (green < 3000, amber < 5000, red ≥ 5000) +- [x] Engine stats row: coolant temp (°C), fuel level (%), throttle (%) +- [x] MIL/DTC card: check-engine warning (amber = MIL on, green = no MIL) + stored DTC count +- [x] GPS card: fix status, lat/lon, fix age, thread alive (text — MapLibre map deferred) +- [x] Overview card: trips, total km, all-time max speed, videos, DTCs, odometer +- [x] Connection status bar: Pi WiFi dot + Server dot (green = online), last-update timestamp +- [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 **Depends on: 28.1. Independent of 28.2–28.4.**