diff --git a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt index 0151831..cc39807 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/AldlApplication.kt @@ -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) diff --git a/app/src/main/java/com/example/esp32aldldashboard/repository/ChartPreferencesRepository.kt b/app/src/main/java/com/example/esp32aldldashboard/repository/ChartPreferencesRepository.kt new file mode 100644 index 0000000..a915748 --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/repository/ChartPreferencesRepository.kt @@ -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 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 = 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> = 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 = 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) { + 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 + } + } +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartParameter.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartParameter.kt new file mode 100644 index 0000000..38dbdbe --- /dev/null +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartParameter.kt @@ -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() } + ) +} diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt index 48b2920..4026314 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/charts/ChartsScreen.kt @@ -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, + 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() } + 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>().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, + 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, + histories: Map>, + 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, + 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, 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()) ) } } diff --git a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt index 3bdd4c4..6e920d9 100644 --- a/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt +++ b/app/src/main/java/com/example/esp32aldldashboard/ui/main/MainScreen.kt @@ -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(