feat: add multi-parameter chart view with graph icon

This commit is contained in:
2026-06-12 10:52:03 +01:00
parent 05a5acac10
commit f760a9f300
5 changed files with 452 additions and 23 deletions
@@ -2,6 +2,7 @@ package com.example.esp32aldldashboard
import android.app.Application
import com.example.esp32aldldashboard.bluetooth.BluetoothService
import com.example.esp32aldldashboard.repository.ChartPreferencesRepository
import com.example.esp32aldldashboard.repository.SettingsRepository
import com.example.esp32aldldashboard.repository.TelemetryRepository
import com.example.esp32aldldashboard.logging.CsvLogger
@@ -15,11 +16,13 @@ class AldlApplication : Application() {
lateinit var csvLogger: CsvLogger
lateinit var rawStreamLogger: RawStreamLogger
lateinit var chartPreferencesRepository: ChartPreferencesRepository
override fun onCreate() {
super.onCreate()
val database = TelemetryDatabase.getDatabase(this)
settingsRepository = SettingsRepository(this)
chartPreferencesRepository = ChartPreferencesRepository(this)
csvLogger = CsvLogger(this)
rawStreamLogger = RawStreamLogger(this)
bluetoothService = BluetoothService(this, rawStreamLogger, settingsRepository)
@@ -0,0 +1,96 @@
package com.example.esp32aldldashboard.repository
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import com.example.esp32aldldashboard.ui.charts.ChartParameter
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
val Context.chartPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "chart_preferences")
class ChartPreferencesRepository(private val context: Context) {
companion object {
val CHART_VIEW_MODE = stringPreferencesKey("chart_view_mode")
val SELECTED_PARAMETERS = stringSetPreferencesKey("selected_parameters")
val SINGLE_CHART_PARAMETER = stringPreferencesKey("single_chart_parameter")
}
enum class ViewMode {
SINGLE, MULTI
}
val viewModeFlow: Flow<ViewMode> = context.chartPreferencesDataStore.data
.map { preferences ->
val modeString = preferences[CHART_VIEW_MODE] ?: ViewMode.MULTI.name
try {
ViewMode.valueOf(modeString)
} catch (e: IllegalArgumentException) {
ViewMode.MULTI
}
}
val selectedParametersFlow: Flow<Set<ChartParameter>> = context.chartPreferencesDataStore.data
.map { preferences ->
val paramNames = preferences[SELECTED_PARAMETERS] ?: setOf(
ChartParameter.RPM.name,
ChartParameter.MAP.name,
ChartParameter.TPS.name,
ChartParameter.O2_SENSOR.name
)
paramNames.mapNotNull { name ->
try {
ChartParameter.valueOf(name)
} catch (e: IllegalArgumentException) {
null
}
}.toSet()
}
val singleChartParameterFlow: Flow<ChartParameter> = context.chartPreferencesDataStore.data
.map { preferences ->
val paramName = preferences[SINGLE_CHART_PARAMETER] ?: ChartParameter.RPM.name
try {
ChartParameter.valueOf(paramName)
} catch (e: IllegalArgumentException) {
ChartParameter.RPM
}
}
suspend fun setViewMode(mode: ViewMode) {
context.chartPreferencesDataStore.edit { preferences ->
preferences[CHART_VIEW_MODE] = mode.name
}
}
suspend fun setSelectedParameters(parameters: Set<ChartParameter>) {
context.chartPreferencesDataStore.edit { preferences ->
preferences[SELECTED_PARAMETERS] = parameters.map { it.name }.toSet()
}
}
suspend fun setSingleChartParameter(parameter: ChartParameter) {
context.chartPreferencesDataStore.edit { preferences ->
preferences[SINGLE_CHART_PARAMETER] = parameter.name
}
}
suspend fun toggleParameter(parameter: ChartParameter) {
context.chartPreferencesDataStore.edit { preferences ->
val current = preferences[SELECTED_PARAMETERS] ?: setOf(
ChartParameter.RPM.name,
ChartParameter.MAP.name,
ChartParameter.TPS.name,
ChartParameter.O2_SENSOR.name
)
val updated = if (current.contains(parameter.name)) {
current - parameter.name
} else {
current + parameter.name
}
preferences[SELECTED_PARAMETERS] = updated
}
}
}
@@ -0,0 +1,84 @@
package com.example.esp32aldldashboard.ui.charts
import androidx.compose.ui.graphics.Color
import com.example.esp32aldldashboard.parser.ALDLFrame
enum class ChartParameter(
val displayName: String,
val color: Color,
val maxValue: Float,
val extractValue: (ALDLFrame) -> Float
) {
RPM(
displayName = "RPM",
color = Color(0xFF00FFCC),
maxValue = 6000f,
extractValue = { it.engineSpeedRpm.toFloat() }
),
COOLANT_TEMP(
displayName = "Coolant Temp",
color = Color(0xFFFF5722),
maxValue = 250f,
extractValue = { it.coolantTempC }
),
MAP(
displayName = "MAP",
color = Color(0xFF2196F3),
maxValue = 105f,
extractValue = { it.mapKpa }
),
TPS(
displayName = "TPS",
color = Color(0xFF9C27B0),
maxValue = 5.5f,
extractValue = { it.tpsVolts }
),
O2_SENSOR(
displayName = "O2 Sensor",
color = Color(0xFF4CAF50),
maxValue = 1000f,
extractValue = { it.o2SensorMv }
),
BATTERY(
displayName = "Battery",
color = Color(0xFFFFEB3B),
maxValue = 16f,
extractValue = { it.batteryVolts }
),
SPARK_ADVANCE(
displayName = "Spark Advance",
color = Color(0xFF00BCD4),
maxValue = 40f,
extractValue = { it.sparkAdvance }
),
BPW(
displayName = "BPW",
color = Color(0xFFE91E63),
maxValue = 15f,
extractValue = { it.bpwMs }
),
MAT(
displayName = "MAT",
color = Color(0xFF795548),
maxValue = 80f,
extractValue = { it.matC }
),
BLM(
displayName = "BLM",
color = Color(0xFF3F51B5),
maxValue = 160f,
extractValue = { it.blm.toFloat() }
),
INTEGRATOR(
displayName = "Integrator",
color = Color(0xFF607D8B),
maxValue = 255f,
extractValue = { it.integrator.toFloat() }
),
IAC(
displayName = "IAC Position",
color = Color(0xFFFF9800),
maxValue = 255f,
extractValue = { it.iacPosition.toFloat() }
)
}
@@ -2,8 +2,11 @@ package com.example.esp32aldldashboard.ui.charts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
@@ -12,66 +15,308 @@ 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 com.example.esp32aldldashboard.repository.ChartPreferencesRepository
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@Composable
fun ChartsScreen(
latestFrameFlow: StateFlow<ALDLFrame?>,
chartPreferencesRepository: ChartPreferencesRepository,
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 viewMode by chartPreferencesRepository.viewModeFlow.collectAsStateWithLifecycle(
initialValue = ChartPreferencesRepository.ViewMode.MULTI
)
val selectedParameters by chartPreferencesRepository.selectedParametersFlow.collectAsStateWithLifecycle(
initialValue = setOf(ChartParameter.RPM, ChartParameter.MAP, ChartParameter.TPS, ChartParameter.O2_SENSOR)
)
val singleChartParameter by chartPreferencesRepository.singleChartParameterFlow.collectAsStateWithLifecycle(
initialValue = ChartParameter.RPM
)
// History storage for all parameters
val maxHistorySize = 100
val histories = remember {
mutableStateMapOf<ChartParameter, MutableList<Float>>().apply {
ChartParameter.values().forEach { put(it, mutableStateListOf()) }
}
}
LaunchedEffect(latestFrame) {
latestFrame?.let {
rpmHistory.add(it.engineSpeedRpm.toFloat())
if (rpmHistory.size > maxHistorySize) {
rpmHistory.removeAt(0)
latestFrame?.let { frame ->
ChartParameter.values().forEach { param ->
val history = histories.getOrPut(param) { mutableStateListOf() }
history.add(param.extractValue(frame))
if (history.size > maxHistorySize) {
history.removeAt(0)
}
}
}
}
val coroutineScope = rememberCoroutineScope()
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Text(
text = "Real-Time Telemetry",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
// Header with view mode toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Real-Time Telemetry",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
// View mode toggle
Row {
FilterChip(
selected = viewMode == ChartPreferencesRepository.ViewMode.SINGLE,
onClick = {
coroutineScope.launch {
chartPreferencesRepository.setViewMode(
if (viewMode == ChartPreferencesRepository.ViewMode.SINGLE)
ChartPreferencesRepository.ViewMode.MULTI
else
ChartPreferencesRepository.ViewMode.SINGLE
)
}
},
label = { Text(if (viewMode == ChartPreferencesRepository.ViewMode.SINGLE) "Single" else "Multi") }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
when (viewMode) {
ChartPreferencesRepository.ViewMode.SINGLE -> {
// Single chart mode
SingleChartView(
selectedParameter = singleChartParameter,
history = histories[singleChartParameter] ?: emptyList(),
onParameterChange = { param ->
coroutineScope.launch {
chartPreferencesRepository.setSingleChartParameter(param)
}
}
)
}
ChartPreferencesRepository.ViewMode.MULTI -> {
// Multi chart mode
MultiChartView(
selectedParameters = selectedParameters,
histories = histories,
onToggleParameter = { param ->
coroutineScope.launch {
chartPreferencesRepository.toggleParameter(param)
}
}
)
}
}
}
}
@Composable
private fun SingleChartView(
selectedParameter: ChartParameter,
history: List<Float>,
onParameterChange: (ChartParameter) -> Unit
) {
Column {
// Parameter selector dropdown
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = selectedParameter.displayName,
onValueChange = {},
readOnly = true,
label = { Text("Parameter") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
ChartParameter.values().forEach { param ->
DropdownMenuItem(
text = { Text(param.displayName) },
onClick = {
onParameterChange(param)
expanded = false }
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Large single chart
Card(
modifier = Modifier.fillMaxWidth().height(200.dp),
modifier = Modifier.fillMaxWidth().height(300.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E))
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "RPM", color = Color(0xFF00FFCC))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = selectedParameter.displayName,
color = selectedParameter.color,
style = MaterialTheme.typography.titleMedium
)
if (history.isNotEmpty()) {
Text(
text = String.format("%.1f", history.last()),
color = selectedParameter.color,
style = MaterialTheme.typography.titleMedium
)
}
}
Spacer(modifier = Modifier.height(8.dp))
LineChart(
data = rpmHistory,
maxValue = 6000f,
lineColor = Color(0xFF00FFCC),
data = history,
maxValue = selectedParameter.maxValue,
lineColor = selectedParameter.color,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
@Composable
private fun MultiChartView(
selectedParameters: Set<ChartParameter>,
histories: Map<ChartParameter, List<Float>>,
onToggleParameter: (ChartParameter) -> Unit
) {
Column {
// Parameter toggle chips
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
items(ChartParameter.values().toList()) { param ->
val isSelected = selectedParameters.contains(param)
FilterChip(
selected = isSelected,
onClick = { onToggleParameter(param) },
label = { Text(param.displayName) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = param.color.copy(alpha = 0.3f),
selectedLabelColor = param.color
)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Additional charts can go here (e.g., O2, TPS)
// 2x2 grid of charts (up to 4)
val activeParams = selectedParameters.take(4)
if (activeParams.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().height(200.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Select parameters above to display charts",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
activeParams.chunked(2).forEach { rowParams ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
rowParams.forEach { param ->
val history = histories[param] ?: emptyList()
ChartCard(
parameter = param,
history = history,
modifier = Modifier.weight(1f)
)
}
// Fill remaining space if odd number
if (rowParams.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
}
@Composable
fun LineChart(
private fun ChartCard(
parameter: ChartParameter,
history: List<Float>,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.height(180.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E))
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = parameter.displayName,
color = parameter.color,
style = MaterialTheme.typography.bodyMedium
)
if (history.isNotEmpty()) {
Text(
text = String.format("%.1f", history.last()),
color = parameter.color,
style = MaterialTheme.typography.bodyMedium
)
}
}
Spacer(modifier = Modifier.height(4.dp))
LineChart(
data = history,
maxValue = parameter.maxValue,
lineColor = parameter.color,
modifier = Modifier.fillMaxSize()
)
}
}
}
@Composable
private fun LineChart(
data: List<Float>,
maxValue: Float,
lineColor: Color,
modifier: Modifier = Modifier
) {
if (data.isEmpty()) return
if (data.isEmpty()) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Text(
text = "Waiting for data...",
color = Color.Gray,
style = MaterialTheme.typography.bodySmall
)
}
return
}
Canvas(modifier = modifier) {
val width = size.width
@@ -94,7 +339,7 @@ fun LineChart(
drawPath(
path = path,
color = lineColor,
style = Stroke(width = 4.dp.toPx())
style = Stroke(width = 3.dp.toPx())
)
}
}
@@ -7,9 +7,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.material.icons.filled.ShowChart
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
@@ -89,7 +89,7 @@ fun MainScreen(
colors = NavigationBarItemDefaults.colors(selectedIconColor = NeonCyan, unselectedIconColor = TextMuted)
)
NavigationBarItem(
icon = { Icon(Icons.Default.Build, contentDescription = "Charts") },
icon = { Icon(Icons.Default.ShowChart, contentDescription = "Charts") },
label = { Text("Charts") },
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
@@ -117,6 +117,7 @@ fun MainScreen(
)
1 -> ChartsScreen(
latestFrameFlow = viewModel.latestFrame,
chartPreferencesRepository = app.chartPreferencesRepository,
modifier = modifier.padding(paddingValues)
)
2 -> SettingsScreen(