feat(android): live dashboard screen (Phase 28.4)
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.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 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).**
|
||||||
@@ -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
|
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.)
|
the file is already on the server next time it scans.)
|
||||||
|
|
||||||
**28.4 — Live dashboard**
|
**28.4 — Live dashboard ✅ DONE (2026-08-15)**
|
||||||
- GPS position on a MapLibre map (updates every 2s from `/api/gps/status`)
|
- `DashboardViewModel` polls `GET /api/gps/status` + `GET /api/obd/status` + `GET /api/stats`
|
||||||
- Speed, RPM, coolant temp, fuel level, throttle cards (from `/api/obd/status` +
|
every 2s (start/stop on tab focus), plus a server `/health` check for the cellular dot.
|
||||||
socket.io — or polling `/api/stats` every few seconds)
|
- `DashboardScreen` (new first tab): big speed + RPM cards (RPM color scale), engine stats row
|
||||||
- MIL/DTC indicator (from `/api/dtc`)
|
(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**
|
**28.5 — Trip history**
|
||||||
- List trips from `/api/trips` with date, distance, duration, start/end locations
|
- List trips from `/api/trips` with date, distance, duration, start/end locations
|
||||||
|
|||||||
@@ -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.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.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
|
||||||
@@ -52,6 +53,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun YarisMainScreen(client: OkHttpClient) {
|
fun YarisMainScreen(client: OkHttpClient) {
|
||||||
val dashcamVm: DashcamViewModel = viewModel()
|
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 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()
|
||||||
@@ -88,18 +96,24 @@ fun YarisMainScreen(client: OkHttpClient) {
|
|||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = selectedTab == 0,
|
selected = selectedTab == 0,
|
||||||
onClick = { selectedTab = 0 },
|
onClick = { selectedTab = 0 },
|
||||||
icon = { Icon(Icons.Filled.Videocam, contentDescription = "Dashcam") },
|
icon = { Icon(Icons.Filled.Dashboard, contentDescription = "Dashboard") },
|
||||||
label = { Text("Dashcam") }
|
label = { Text("Dashboard") }
|
||||||
)
|
)
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = selectedTab == 1,
|
selected = selectedTab == 1,
|
||||||
onClick = { selectedTab = 1 },
|
onClick = { selectedTab = 1 },
|
||||||
icon = { Icon(Icons.Filled.FileDownload, contentDescription = "Downloads") },
|
icon = { Icon(Icons.Filled.Videocam, contentDescription = "Dashcam") },
|
||||||
label = { Text("Downloads") }
|
label = { Text("Dashcam") }
|
||||||
)
|
)
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = selectedTab == 2,
|
selected = selectedTab == 2,
|
||||||
onClick = { 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") },
|
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
|
||||||
label = { Text("Settings") }
|
label = { Text("Settings") }
|
||||||
)
|
)
|
||||||
@@ -108,7 +122,17 @@ fun YarisMainScreen(client: OkHttpClient) {
|
|||||||
) { padding ->
|
) { padding ->
|
||||||
Column(modifier = Modifier.padding(padding)) {
|
Column(modifier = Modifier.padding(padding)) {
|
||||||
when (selectedTab) {
|
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,
|
videos = videos,
|
||||||
totalSize = totalSize,
|
totalSize = totalSize,
|
||||||
retentionDays = retentionDays,
|
retentionDays = retentionDays,
|
||||||
@@ -125,7 +149,7 @@ fun YarisMainScreen(client: OkHttpClient) {
|
|||||||
onDeleteDashcam = { dashcamVm.deleteDashcamFile(it) },
|
onDeleteDashcam = { dashcamVm.deleteDashcamFile(it) },
|
||||||
onImport = { dashcamVm.importDashcamFiles(it) },
|
onImport = { dashcamVm.importDashcamFiles(it) },
|
||||||
)
|
)
|
||||||
1 -> DownloadsScreen(
|
2 -> DownloadsScreen(
|
||||||
files = downloadedFiles,
|
files = downloadedFiles,
|
||||||
activeDownloads = activeDownloads,
|
activeDownloads = activeDownloads,
|
||||||
activeUploads = uploadProgress,
|
activeUploads = uploadProgress,
|
||||||
@@ -141,7 +165,7 @@ fun YarisMainScreen(client: OkHttpClient) {
|
|||||||
onGenerateTestFile = { dashcamVm.generateTestFile() },
|
onGenerateTestFile = { dashcamVm.generateTestFile() },
|
||||||
onRefresh = { dashcamVm.scanDownloads() },
|
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.DashcamFilesResponse
|
||||||
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.Stats
|
||||||
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
|
||||||
@@ -21,6 +23,14 @@ interface PiApi {
|
|||||||
@GET("/api/gps/status")
|
@GET("/api/gps/status")
|
||||||
suspend fun getGpsStatus(): GpsStatus
|
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) */
|
/** 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/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
@@ -154,27 +154,29 @@ android/
|
|||||||
- [x] Retry button per failed file (tracks failed uploads via `failedUploads` StateFlow)
|
- [x] Retry button per failed file (tracks failed uploads via `failedUploads` StateFlow)
|
||||||
- [x] Commit: `feat: upload queue screen — upload all + per-file retry`
|
- [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.**
|
**Depends on: 28.1 (networking layer). Independent of 28.2/28.3.**
|
||||||
|
|
||||||
#### 28.4.1 — Data layer
|
#### 28.4.1 — Data layer ✅
|
||||||
- [ ] Retrofit interface additions to `PiApi`:
|
- [x] Retrofit interface additions to `PiApi`:
|
||||||
- `GET /api/gps/status` → `GpsStatus` (has_fix, lat, lon, fix_age_seconds, thread_alive)
|
- `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/obd/status` → `ObdStatus` (adapter_present, port, car_connected, speed, rpm,
|
||||||
- `GET /api/stats` → `Stats` (speed, rpm, coolant_temp, fuel_level, throttle_pos, mil_on, dtc_count, odometer_km)
|
coolant_temp, fuel_level, throttle_position, mil_on)
|
||||||
- [ ] Polling scheduler: coroutine `while(true) { delay(2000); fetch() }` scoped to ViewModel
|
- `GET /api/stats` → `Stats` (trip_count, total_km, active_dtc, video_count, odometer_km)
|
||||||
- [ ] Commit: `feat: dashboard data polling`
|
- [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
|
#### 28.4.2 — Dashboard UI ✅ (map deferred)
|
||||||
- [ ] GPS card: MapLibre MapView centered on last fix with car marker
|
- [x] Speed card: large number (km/h), "OBD speed"/"OBD offline" source label
|
||||||
- [ ] Speed card: large number (km/h), OBD or GPS source label
|
- [x] RPM card: number with color scale (green < 3000, amber < 5000, red ≥ 5000)
|
||||||
- [ ] RPM card: gauge or number with color scale (green → yellow → red)
|
- [x] Engine stats row: coolant temp (°C), fuel level (%), throttle (%)
|
||||||
- [ ] 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
|
||||||
- [ ] MIL/DTC indicator: check-engine icon (green/grey = no MIL, yellow/red = MIL on)
|
- [x] GPS card: fix status, lat/lon, fix age, thread alive (text — MapLibre map deferred)
|
||||||
with DTC code count badge
|
- [x] Overview card: trips, total km, all-time max speed, videos, DTCs, odometer
|
||||||
- [ ] Connection status bar: Pi WiFi icon (green = connected), cellular icon (green = online)
|
- [x] Connection status bar: Pi WiFi dot + Server dot (green = online), last-update timestamp
|
||||||
- [ ] Auto-refresh every 2s, pause when app is backgrounded
|
- [x] Auto-refresh every 2s; polling stops when navigating away from the tab
|
||||||
- [ ] Commit: `feat: live dashboard screen`
|
- [ ] GPS map (MapLibre) — deferred: needs the MapLibre dep + basemap over hotspot (no internet).
|
||||||
|
|
||||||
### 28.5 — Trip history ⬜ NOT STARTED
|
### 28.5 — Trip history ⬜ NOT STARTED
|
||||||
**Depends on: 28.1. Independent of 28.2–28.4.**
|
**Depends on: 28.1. Independent of 28.2–28.4.**
|
||||||
|
|||||||
Reference in New Issue
Block a user