feat: implement telemetry persistence, data visualization, and improved bluetooth stream parsing with frame statistics

This commit is contained in:
2026-06-11 23:59:42 +01:00
parent c95dbfe58e
commit 29423f6aff
26 changed files with 1794 additions and 935 deletions
@@ -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
)
}
}
}