feat: implement telemetry persistence, data visualization, and improved bluetooth stream parsing with frame statistics
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package com.example.esp32aldldashboard.ui.charts
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.example.esp32aldldashboard.parser.ALDLFrame
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Composable
|
||||
fun ChartsScreen(
|
||||
latestFrameFlow: StateFlow<ALDLFrame?>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val latestFrame by latestFrameFlow.collectAsStateWithLifecycle()
|
||||
|
||||
// We maintain a limited rolling history of points (e.g. 100 points)
|
||||
val rpmHistory = remember { mutableStateListOf<Float>() }
|
||||
val maxHistorySize = 100
|
||||
|
||||
LaunchedEffect(latestFrame) {
|
||||
latestFrame?.let {
|
||||
rpmHistory.add(it.engineSpeedRpm.toFloat())
|
||||
if (rpmHistory.size > maxHistorySize) {
|
||||
rpmHistory.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text(
|
||||
text = "Real-Time Telemetry",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().height(200.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(text = "RPM", color = Color(0xFF00FFCC))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LineChart(
|
||||
data = rpmHistory,
|
||||
maxValue = 6000f,
|
||||
lineColor = Color(0xFF00FFCC),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Additional charts can go here (e.g., O2, TPS)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LineChart(
|
||||
data: List<Float>,
|
||||
maxValue: Float,
|
||||
lineColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (data.isEmpty()) return
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val pointSpacing = if (data.size > 1) width / (data.size - 1) else 0f
|
||||
|
||||
val path = Path()
|
||||
data.forEachIndexed { index, value ->
|
||||
val x = index * pointSpacing
|
||||
// Invert y since Canvas y=0 is at the top
|
||||
val y = height - ((value / maxValue) * height).coerceIn(0f, height)
|
||||
|
||||
if (index == 0) {
|
||||
path.moveTo(x, y)
|
||||
} else {
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = path,
|
||||
color = lineColor,
|
||||
style = Stroke(width = 4.dp.toPx())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.example.esp32aldldashboard.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun RpmGauge(
|
||||
rpm: Int,
|
||||
maxRpm: Int = 6000,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val animatedRpm by animateFloatAsState(
|
||||
targetValue = rpm.toFloat(),
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
|
||||
label = "rpmAnimation"
|
||||
)
|
||||
|
||||
Box(modifier = modifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
|
||||
Canvas(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
val strokeWidth = 24.dp.toPx()
|
||||
val startAngle = 135f
|
||||
val sweepAngle = 270f
|
||||
|
||||
// Background arc
|
||||
drawArc(
|
||||
color = Color.DarkGray.copy(alpha = 0.5f),
|
||||
startAngle = startAngle,
|
||||
sweepAngle = sweepAngle,
|
||||
useCenter = false,
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
|
||||
size = Size(size.width, size.height)
|
||||
)
|
||||
|
||||
// Foreground arc
|
||||
val progress = (animatedRpm / maxRpm).coerceIn(0f, 1f)
|
||||
val color = if (progress > 0.85f) Color.Red else Color(0xFF00FFCC) // Neon Cyan
|
||||
|
||||
drawArc(
|
||||
color = color,
|
||||
startAngle = startAngle,
|
||||
sweepAngle = sweepAngle * progress,
|
||||
useCenter = false,
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
|
||||
size = Size(size.width, size.height)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = rpm.toString(),
|
||||
color = Color.White,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "RPM",
|
||||
color = Color.LightGray,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TpsBar(
|
||||
tpsVolts: Float,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val animatedTps by animateFloatAsState(
|
||||
targetValue = tpsVolts,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessLow),
|
||||
label = "tpsAnimation"
|
||||
)
|
||||
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val strokeWidth = size.height
|
||||
val maxVolts = 5.0f
|
||||
val progress = (animatedTps / maxVolts).coerceIn(0f, 1f)
|
||||
|
||||
// Background bar
|
||||
drawLine(
|
||||
color = Color.DarkGray.copy(alpha = 0.5f),
|
||||
start = Offset(0f, size.height / 2),
|
||||
end = Offset(size.width, size.height / 2),
|
||||
strokeWidth = strokeWidth,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
|
||||
// Foreground bar
|
||||
drawLine(
|
||||
color = Color(0xFFFF9900), // Neon Orange
|
||||
start = Offset(0f, size.height / 2),
|
||||
end = Offset(size.width * progress, size.height / 2),
|
||||
strokeWidth = strokeWidth,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = String.format("%.2f V", tpsVolts),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
package com.example.esp32aldldashboard.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.esp32aldldashboard.bluetooth.ConnectionState
|
||||
import com.example.esp32aldldashboard.parser.ALDLFrame
|
||||
import com.example.esp32aldldashboard.parser.TroubleCodeDictionary
|
||||
import com.example.esp32aldldashboard.ui.components.RpmGauge
|
||||
import com.example.esp32aldldashboard.ui.components.TpsBar
|
||||
|
||||
// Theme Colors
|
||||
val DarkBg = Color(0xFF0F0F12)
|
||||
val CardBg = Color(0xFF1B1B22)
|
||||
val BorderColor = Color(0xFF2E2E38)
|
||||
val NeonCyan = Color(0xFF00E5FF)
|
||||
val NeonRed = Color(0xFFFF3D00)
|
||||
val NeonGreen = Color(0xFF00E676)
|
||||
val NeonOrange = Color(0xFFFF9100)
|
||||
val TextWhite = Color(0xFFEEEEEE)
|
||||
val TextMuted = Color(0xFF9E9EAF)
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
connState: ConnectionState,
|
||||
frame: ALDLFrame?,
|
||||
isCelsius: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onSimulate: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(DarkBg)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// App Title Banner
|
||||
Text(
|
||||
text = "PONTIAC FIERO ALDL DASHBOARD",
|
||||
color = NeonOrange,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
letterSpacing = 1.5.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
|
||||
// Connection Action Card
|
||||
ConnectionCard(
|
||||
connState = connState,
|
||||
errorMsg = "",
|
||||
isCelsius = isCelsius,
|
||||
onConnect = onConnect,
|
||||
onDisconnect = onDisconnect,
|
||||
onSimulate = onSimulate,
|
||||
onToggleUnit = { /* Moved to settings */ }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Telemetry Panels
|
||||
if (frame != null) {
|
||||
// Trouble Codes Card (Flashing if active)
|
||||
if (frame.activeFaultCodes.isNotEmpty()) {
|
||||
TroubleCodesCard(activeCodes = frame.activeFaultCodes)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Gauges row: RPM and TPS
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
RpmGauge(
|
||||
rpm = frame.engineSpeedRpm,
|
||||
modifier = Modifier.weight(1f).aspectRatio(1f)
|
||||
)
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = Modifier.weight(1f).aspectRatio(1f)
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = "THROTTLE", color = TextMuted, fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TpsBar(tpsVolts = frame.tpsVolts, modifier = Modifier.fillMaxWidth().height(40.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Grid of Minor Telemetry Parameters
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Coolant and Intake Temp Row
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
val coolantVal = if (isCelsius) frame.coolantTempC else frame.coolantTempF
|
||||
val coolantUnit = if (isCelsius) "°C" else "°F"
|
||||
val coolantProgress = (coolantVal + 40) / 290f // normalized range
|
||||
|
||||
val matVal = if (isCelsius) frame.matC else frame.matF
|
||||
val matProgress = (matVal + 40) / 290f
|
||||
|
||||
GridItemCard(
|
||||
title = "COOLANT TEMP",
|
||||
value = String.format("%.1f", coolantVal) + coolantUnit,
|
||||
progress = coolantProgress,
|
||||
progressColor = if (coolantVal > 210) NeonRed else NeonGreen,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
GridItemCard(
|
||||
title = "MAT (AIR TEMP)",
|
||||
value = String.format("%.1f", matVal) + coolantUnit,
|
||||
progress = matProgress,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Fuel control row
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GridItemCard(
|
||||
title = "BPW (INJECTOR)",
|
||||
value = String.format("%.3f ms", frame.bpwMs),
|
||||
progress = frame.bpwMs / 15f,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
GridItemCard(
|
||||
title = "O2 SENSOR",
|
||||
value = "${frame.o2SensorMv.toInt()} mV",
|
||||
progress = frame.o2SensorMv / 1000f,
|
||||
progressColor = if (frame.isRich) NeonGreen else NeonOrange,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Air flow & Throttle Position
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GridItemCard(
|
||||
title = "VEHICLE SPEED",
|
||||
value = "${frame.vehicleSpeedMPH} MPH",
|
||||
progress = frame.vehicleSpeedMPH / 120f,
|
||||
progressColor = NeonGreen,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
GridItemCard(
|
||||
title = "MAP (VACUUM)",
|
||||
value = String.format("%.1f kPa", frame.mapKpa),
|
||||
progress = frame.mapKpa / 105f,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Fuel trims (BLM & INT) & Battery
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GridItemCard(
|
||||
title = "BLM / INT",
|
||||
value = "${frame.blm} / ${frame.integrator}",
|
||||
progress = frame.blm / 256f,
|
||||
progressColor = if (frame.blm in 120..136) NeonGreen else NeonOrange,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
GridItemCard(
|
||||
title = "BATTERY VOLTS",
|
||||
value = String.format("%.1f V", frame.batteryVolts),
|
||||
progress = (frame.batteryVolts - 8) / 8f,
|
||||
progressColor = if (frame.batteryVolts < 12.0f) NeonRed else NeonGreen,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// IAC, Spark & EGR
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GridItemCard(
|
||||
title = "IAC POSITION",
|
||||
value = "${frame.iacPosition} Steps",
|
||||
progress = frame.iacPosition / 160f,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
GridItemCard(
|
||||
title = "SPARK / EGR",
|
||||
value = String.format("%.1f° / %.0f%%", frame.sparkAdvance, frame.egrDutyCycle),
|
||||
progress = frame.egrDutyCycle / 100f,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Status Badges Section
|
||||
StatusFlagsPanel(frame = frame)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
// Empty / Waiting display
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(CardBg, shape = RoundedCornerShape(12.dp))
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Waiting for ALDL Stream data...\nSelect Connection or Simulation above.",
|
||||
color = TextMuted,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ... Copying the UI components from MainScreen.kt to keep them in DashboardScreen.kt ...
|
||||
|
||||
@Composable
|
||||
fun ConnectionCard(
|
||||
connState: ConnectionState,
|
||||
errorMsg: String,
|
||||
isCelsius: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onSimulate: () -> Unit,
|
||||
onToggleUnit: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
// Status bar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "STATUS: ",
|
||||
color = TextMuted,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
val (statusText, statusColor) = when (connState) {
|
||||
ConnectionState.DISCONNECTED -> "DISCONNECTED" to TextMuted
|
||||
ConnectionState.CONNECTING -> "CONNECTING..." to NeonOrange
|
||||
ConnectionState.CONNECTED -> "CONNECTED" to NeonGreen
|
||||
ConnectionState.ERROR -> "ERROR" to NeonRed
|
||||
}
|
||||
Text(
|
||||
text = statusText,
|
||||
color = statusColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = errorMsg,
|
||||
color = NeonRed,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
// Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (connState != ConnectionState.CONNECTED && connState != ConnectionState.CONNECTING) {
|
||||
Button(
|
||||
onClick = onConnect,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NeonOrange),
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("CONNECT BT", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onDisconnect,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("DISCONNECT", fontWeight = FontWeight.Bold, color = TextWhite)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSimulate,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("SIMULATE", fontWeight = FontWeight.Bold, color = NeonCyan)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GridItemCard(
|
||||
title: String,
|
||||
value: String,
|
||||
progress: Float,
|
||||
progressColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = modifier
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(10.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = TextMuted,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = value,
|
||||
color = TextWhite,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.coerceIn(0f, 1f) },
|
||||
color = progressColor,
|
||||
trackColor = BorderColor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TroubleCodesCard(activeCodes: List<Int>) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.border(1.dp, NeonRed, shape = RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "⚠️ ACTIVE ECM FAULT CODES",
|
||||
color = NeonRed,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
activeCodes.forEach { code ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(NeonRed, shape = RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "CODE $code",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = TroubleCodeDictionary.getDescription(code),
|
||||
color = TextWhite,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun StatusFlagsPanel(frame: ALDLFrame) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "LOOP STATUS & SYSTEM SWITCHES",
|
||||
color = TextMuted,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
StatusBadge(label = "Closed Loop", active = frame.isClosedLoop, activeColor = NeonGreen)
|
||||
StatusBadge(label = "Rich Mixture", active = frame.isRich, activeColor = NeonGreen)
|
||||
StatusBadge(label = "BLM Enabled", active = frame.blmEnable, activeColor = NeonGreen)
|
||||
StatusBadge(label = "TCC Locked", active = frame.isTccLocked, activeColor = NeonGreen)
|
||||
StatusBadge(label = "AC Clutch", active = frame.isAcClutchEnabled, activeColor = NeonGreen)
|
||||
StatusBadge(label = "Park/Neutral", active = frame.isParkNeutral, activeColor = NeonCyan)
|
||||
StatusBadge(label = "A/C Request", active = frame.isAcEnabled, activeColor = NeonCyan)
|
||||
StatusBadge(label = "PS Cramp", active = frame.isPowerSteeringCrampActive, activeColor = NeonOrange)
|
||||
StatusBadge(label = "Async Pulse", active = frame.asyncPulse, activeColor = NeonOrange)
|
||||
StatusBadge(label = "Quasi Pulse", active = frame.quasiPulse, activeColor = NeonOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusBadge(label: String, active: Boolean, activeColor: Color) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = if (active) activeColor.copy(alpha = 0.15f) else Color.Transparent,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = if (active) activeColor else BorderColor,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (active) activeColor else TextMuted,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,45 +5,24 @@ import android.content.pm.PackageManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.example.esp32aldldashboard.bluetooth.ConnectionState
|
||||
import com.example.esp32aldldashboard.parser.ALDLFrame
|
||||
|
||||
// Theme Colors
|
||||
private val DarkBg = Color(0xFF0F0F12)
|
||||
private val CardBg = Color(0xFF1B1B22)
|
||||
private val BorderColor = Color(0xFF2E2E38)
|
||||
private val NeonCyan = Color(0xFF00E5FF)
|
||||
private val NeonRed = Color(0xFFFF3D00)
|
||||
private val NeonGreen = Color(0xFF00E676)
|
||||
private val NeonOrange = Color(0xFFFF9100)
|
||||
private val TextWhite = Color(0xFFEEEEEE)
|
||||
private val TextMuted = Color(0xFF9E9EAF)
|
||||
import com.example.esp32aldldashboard.AldlApplication
|
||||
import com.example.esp32aldldashboard.ui.charts.ChartsScreen
|
||||
import com.example.esp32aldldashboard.ui.settings.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
onItemClick: (NavKey) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -52,7 +31,6 @@ fun MainScreen(
|
||||
val connState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val frame by viewModel.latestFrame.collectAsStateWithLifecycle()
|
||||
val rawLog by viewModel.rawHexLog.collectAsStateWithLifecycle()
|
||||
val errorMsg by viewModel.errorMessage.collectAsStateWithLifecycle()
|
||||
val isCelsius by viewModel.isCelsius.collectAsStateWithLifecycle()
|
||||
|
||||
val permissionsLauncher = rememberLauncherForActivityResult(
|
||||
@@ -70,7 +48,8 @@ fun MainScreen(
|
||||
val requiredPermissions = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
@@ -90,623 +69,54 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
MainScreenContent(
|
||||
connState = connState,
|
||||
frame = frame,
|
||||
rawLog = rawLog,
|
||||
errorMsg = errorMsg,
|
||||
isCelsius = isCelsius,
|
||||
onConnect = onConnectClick,
|
||||
onDisconnect = { viewModel.disconnect() },
|
||||
onSimulate = { viewModel.startSimulation() },
|
||||
onToggleUnit = { viewModel.toggleTemperatureUnit() },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
val app = context.applicationContext as AldlApplication
|
||||
|
||||
@Composable
|
||||
fun MainScreenContent(
|
||||
connState: ConnectionState,
|
||||
frame: ALDLFrame?,
|
||||
rawLog: List<String>,
|
||||
errorMsg: String,
|
||||
isCelsius: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onSimulate: () -> Unit,
|
||||
onToggleUnit: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(DarkBg)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// App Title Banner
|
||||
Text(
|
||||
text = "PONTIAC FIERO ALDL DASHBOARD",
|
||||
color = NeonOrange,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
letterSpacing = 1.5.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
|
||||
// Connection Action Card
|
||||
ConnectionCard(
|
||||
connState = connState,
|
||||
errorMsg = errorMsg,
|
||||
isCelsius = isCelsius,
|
||||
onConnect = onConnect,
|
||||
onDisconnect = onDisconnect,
|
||||
onSimulate = onSimulate,
|
||||
onToggleUnit = onToggleUnit
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Telemetry Panels
|
||||
if (frame != null) {
|
||||
// Trouble Codes Card (Flashing if active)
|
||||
if (frame.activeFaultCodes.isNotEmpty()) {
|
||||
TroubleCodesCard(activeCodes = frame.activeFaultCodes)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Key Metrics
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
MetricCard(
|
||||
title = "ENGINE SPEED",
|
||||
value = "${frame.engineSpeedRpm}",
|
||||
unit = "RPM",
|
||||
progress = frame.engineSpeedRpm / 6000f,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar(containerColor = DarkBg, contentColor = NeonOrange) {
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Info, contentDescription = "Dashboard") },
|
||||
label = { Text("Dashboard") },
|
||||
selected = selectedTab == 0,
|
||||
onClick = { selectedTab = 0 },
|
||||
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
|
||||
)
|
||||
MetricCard(
|
||||
title = "VEHICLE SPEED",
|
||||
value = "${frame.vehicleSpeedMPH}",
|
||||
unit = "MPH",
|
||||
progress = frame.vehicleSpeedMPH / 120f,
|
||||
progressColor = NeonGreen,
|
||||
modifier = Modifier.weight(1f)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Build, contentDescription = "Charts") },
|
||||
label = { Text("Charts") },
|
||||
selected = selectedTab == 1,
|
||||
onClick = { selectedTab = 1 },
|
||||
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Grid of Minor Telemetry Parameters
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Coolant and Intake Temp Row
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
val coolantVal = if (isCelsius) frame.coolantTempC else frame.coolantTempF
|
||||
val coolantUnit = if (isCelsius) "°C" else "°F"
|
||||
val coolantProgress = (coolantVal + 40) / 290f // normalized range
|
||||
|
||||
val matVal = if (isCelsius) frame.matC else frame.matF
|
||||
val matProgress = (matVal + 40) / 290f
|
||||
|
||||
GridItemCard(
|
||||
title = "COOLANT TEMP",
|
||||
value = String.format("%.1f", coolantVal) + coolantUnit,
|
||||
progress = coolantProgress,
|
||||
progressColor = if (coolantVal > 210) NeonRed else NeonGreen,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
GridItemCard(
|
||||
title = "MAT (AIR TEMP)",
|
||||
value = String.format("%.1f", matVal) + coolantUnit,
|
||||
progress = matProgress,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Fuel control row
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GridItemCard(
|
||||
title = "BPW (INJECTOR)",
|
||||
value = String.format("%.3f ms", frame.bpwMs),
|
||||
progress = frame.bpwMs / 15f,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
GridItemCard(
|
||||
title = "O2 SENSOR",
|
||||
value = "${frame.o2SensorMv.toInt()} mV",
|
||||
progress = frame.o2SensorMv / 1000f,
|
||||
progressColor = if (frame.isRich) NeonGreen else NeonOrange,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Air flow & Throttle Position
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GridItemCard(
|
||||
title = "TPS (THROTTLE)",
|
||||
value = String.format("%.2f V", frame.tpsVolts),
|
||||
progress = frame.tpsVolts / 5.0f,
|
||||
progressColor = NeonGreen,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
GridItemCard(
|
||||
title = "MAP (VACUUM)",
|
||||
value = String.format("%.1f kPa", frame.mapKpa),
|
||||
progress = frame.mapKpa / 105f,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Fuel trims (BLM & INT) & Battery
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GridItemCard(
|
||||
title = "BLM / INT",
|
||||
value = "${frame.blm} / ${frame.integrator}",
|
||||
progress = frame.blm / 256f,
|
||||
progressColor = if (frame.blm in 120..136) NeonGreen else NeonOrange,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
GridItemCard(
|
||||
title = "BATTERY VOLTS",
|
||||
value = String.format("%.1f V", frame.batteryVolts),
|
||||
progress = (frame.batteryVolts - 8) / 8f,
|
||||
progressColor = if (frame.batteryVolts < 12.0f) NeonRed else NeonGreen,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// IAC, Spark & EGR
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GridItemCard(
|
||||
title = "IAC POSITION",
|
||||
value = "${frame.iacPosition} Steps",
|
||||
progress = frame.iacPosition / 160f,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
GridItemCard(
|
||||
title = "SPARK / EGR",
|
||||
value = String.format("%.1f° / %.0f%%", frame.sparkAdvance, frame.egrDutyCycle),
|
||||
progress = frame.egrDutyCycle / 100f,
|
||||
progressColor = NeonCyan,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Status Badges Section
|
||||
StatusFlagsPanel(frame = frame)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
// Empty / Waiting display
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(CardBg, shape = RoundedCornerShape(12.dp))
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Waiting for ALDL Stream data...\nSelect Connection or Simulation above.",
|
||||
color = TextMuted,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||
label = { Text("Settings") },
|
||||
selected = selectedTab == 2,
|
||||
onClick = { selectedTab = 2 },
|
||||
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Live Diagnostic Console log
|
||||
DiagnosticConsole(rawLog = rawLog)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectionCard(
|
||||
connState: ConnectionState,
|
||||
errorMsg: String,
|
||||
isCelsius: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onSimulate: () -> Unit,
|
||||
onToggleUnit: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
// Status bar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "STATUS: ",
|
||||
color = TextMuted,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
val (statusText, statusColor) = when (connState) {
|
||||
ConnectionState.DISCONNECTED -> "DISCONNECTED" to TextMuted
|
||||
ConnectionState.CONNECTING -> "CONNECTING..." to NeonOrange
|
||||
ConnectionState.CONNECTED -> "CONNECTED" to NeonGreen
|
||||
ConnectionState.ERROR -> "ERROR" to NeonRed
|
||||
}
|
||||
Text(
|
||||
text = statusText,
|
||||
color = statusColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
|
||||
// Temperature Toggle Button
|
||||
Button(
|
||||
onClick = onToggleUnit,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = Modifier.height(28.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isCelsius) "USE °F" else "USE °C",
|
||||
color = TextWhite,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = errorMsg,
|
||||
color = NeonRed,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
// Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (connState != ConnectionState.CONNECTED && connState != ConnectionState.CONNECTING) {
|
||||
Button(
|
||||
onClick = onConnect,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NeonOrange),
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("CONNECT BT", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onDisconnect,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("DISCONNECT", fontWeight = FontWeight.Bold, color = TextWhite)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSimulate,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = BorderColor),
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("SIMULATE", fontWeight = FontWeight.Bold, color = NeonCyan)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MetricCard(
|
||||
title: String,
|
||||
value: String,
|
||||
unit: String,
|
||||
progress: Float,
|
||||
progressColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = modifier
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = TextMuted,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
) { paddingValues ->
|
||||
when (selectedTab) {
|
||||
0 -> DashboardScreen(
|
||||
connState = connState,
|
||||
frame = frame,
|
||||
isCelsius = isCelsius,
|
||||
onConnect = onConnectClick,
|
||||
onDisconnect = { viewModel.disconnect() },
|
||||
onSimulate = { viewModel.startSimulation() },
|
||||
modifier = modifier.padding(paddingValues)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
color = TextWhite,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Black
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = unit,
|
||||
color = TextMuted,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = progress.coerceIn(0f, 1f),
|
||||
color = progressColor,
|
||||
trackColor = BorderColor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
1 -> ChartsScreen(
|
||||
latestFrameFlow = viewModel.latestFrame,
|
||||
modifier = modifier.padding(paddingValues)
|
||||
)
|
||||
2 -> SettingsScreen(
|
||||
settingsRepository = app.settingsRepository,
|
||||
modifier = modifier.padding(paddingValues)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GridItemCard(
|
||||
title: String,
|
||||
value: String,
|
||||
progress: Float,
|
||||
progressColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = modifier
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(10.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = TextMuted,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = value,
|
||||
color = TextWhite,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = progress.coerceIn(0f, 1f),
|
||||
color = progressColor,
|
||||
trackColor = BorderColor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TroubleCodesCard(activeCodes: List<Int>) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.border(1.dp, NeonRed, shape = RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "⚠️ ACTIVE ECM FAULT CODES",
|
||||
color = NeonRed,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
activeCodes.forEach { code ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(NeonRed, shape = RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "CODE $code",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Refer to Fiero shop manual for diagnosis.",
|
||||
color = TextMuted,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun StatusFlagsPanel(frame: ALDLFrame) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "LOOP STATUS & SYSTEM SWITCHES",
|
||||
color = TextMuted,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
StatusBadge(label = "Closed Loop", active = frame.isClosedLoop, activeColor = NeonGreen)
|
||||
StatusBadge(label = "Rich Mixture", active = frame.isRich, activeColor = NeonGreen)
|
||||
StatusBadge(label = "BLM Enabled", active = frame.blmEnable, activeColor = NeonGreen)
|
||||
StatusBadge(label = "TCC Locked", active = frame.isTccLocked, activeColor = NeonGreen)
|
||||
StatusBadge(label = "AC Clutch", active = frame.isAcClutchEnabled, activeColor = NeonGreen)
|
||||
StatusBadge(label = "Park/Neutral", active = frame.isParkNeutral, activeColor = NeonCyan)
|
||||
StatusBadge(label = "A/C Request", active = frame.isAcEnabled, activeColor = NeonCyan)
|
||||
StatusBadge(label = "PS Cramp", active = frame.isPowerSteeringCrampActive, activeColor = NeonOrange)
|
||||
StatusBadge(label = "Async Pulse", active = frame.asyncPulse, activeColor = NeonOrange)
|
||||
StatusBadge(label = "Quasi Pulse", active = frame.quasiPulse, activeColor = NeonOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusBadge(label: String, active: Boolean, activeColor: Color) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = if (active) activeColor.copy(alpha = 0.15f) else Color.Transparent,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = if (active) activeColor else BorderColor,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (active) activeColor else TextMuted,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiagnosticConsole(rawLog: List<String>) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = CardBg),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.border(1.dp, BorderColor, shape = RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "DIAGNOSTIC TELEMETRY STREAM LOG",
|
||||
color = TextMuted,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp)
|
||||
.background(Color.Black, shape = RoundedCornerShape(6.dp))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
if (rawLog.isEmpty()) {
|
||||
Text(
|
||||
text = "Console Idle. Connect to view hex dump...",
|
||||
color = TextMuted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp
|
||||
)
|
||||
} else {
|
||||
rawLog.forEach { logLine ->
|
||||
Text(
|
||||
text = logLine,
|
||||
color = NeonGreen,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ class MainScreenViewModel(context: Context) : ViewModel() {
|
||||
val rawHexLog: StateFlow<List<String>> = bluetoothService.rawHexLog
|
||||
val errorMessage: StateFlow<String> = bluetoothService.errorMessage
|
||||
|
||||
val framesReceived: StateFlow<Int> = bluetoothService.framesReceived
|
||||
val parseErrors: StateFlow<Int> = bluetoothService.parseErrors
|
||||
val currentFrameRate: StateFlow<Int> = bluetoothService.currentFrameRate
|
||||
|
||||
private val _isCelsius = MutableStateFlow(false) // Default to Fahrenheit for standard 80s GM telemetry
|
||||
val isCelsius: StateFlow<Boolean> = _isCelsius
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.example.esp32aldldashboard.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.example.esp32aldldashboard.repository.SettingsRepository
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
settingsRepository: SettingsRepository,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isCelsius by settingsRepository.isCelsiusFlow.collectAsStateWithLifecycle(initialValue = false)
|
||||
val autoLogging by settingsRepository.autoLoggingFlow.collectAsStateWithLifecycle(initialValue = false)
|
||||
val coolantThreshold by settingsRepository.coolantAlertThresholdFlow.collectAsStateWithLifecycle(initialValue = 100f)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text(
|
||||
text = "Settings & Alerts",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Temperature Unit Toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(text = "Temperature Unit", style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = if (isCelsius) "Celsius (°C)" else "Fahrenheit (°F)", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = isCelsius,
|
||||
onCheckedChange = {
|
||||
coroutineScope.launch { settingsRepository.setIsCelsius(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Auto Logging Toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(text = "Auto-Log Sessions", style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = "Automatically save CSV and database records.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = autoLogging,
|
||||
onCheckedChange = {
|
||||
coroutineScope.launch { settingsRepository.setAutoLogging(it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Coolant Alert Threshold
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
Text(text = "Coolant Alert Threshold", style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = "Trigger notification when coolant exceeds ${coolantThreshold.toInt()}°C", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Slider(
|
||||
value = coolantThreshold,
|
||||
onValueChange = {
|
||||
coroutineScope.launch { settingsRepository.setCoolantAlertThreshold(it) }
|
||||
},
|
||||
valueRange = 80f..150f,
|
||||
steps = 70
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user