feat(android): live dashboard screen (Phase 28.4)

This commit is contained in:
Mathieu Moran
2026-08-15 19:40:47 -04:00
parent b6f8416b5c
commit cadde5aeee
8 changed files with 542 additions and 30 deletions
@@ -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
}
@@ -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<GpsStatus?>(null)
val gps: StateFlow<GpsStatus?> = _gps.asStateFlow()
private val _obd = MutableStateFlow<ObdStatus?>(null)
val obd: StateFlow<ObdStatus?> = _obd.asStateFlow()
private val _stats = MutableStateFlow<Stats?>(null)
val stats: StateFlow<Stats?> = _stats.asStateFlow()
private val _piOnline = MutableStateFlow(false)
val piOnline: StateFlow<Boolean> = _piOnline.asStateFlow()
private val _serverOnline = MutableStateFlow(false)
val serverOnline: StateFlow<Boolean> = _serverOnline.asStateFlow()
private val _lastUpdate = MutableStateFlow(0L)
val lastUpdate: StateFlow<Long> = _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<Application>().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
}
}
@@ -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)
}
}
}
@@ -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
@@ -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,
)
@@ -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 = "",
)
+19 -17
View File
@@ -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.**