feat(android): live dashboard screen (Phase 28.4)
This commit is contained in:
@@ -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 = "",
|
||||
)
|
||||
Reference in New Issue
Block a user