feat: add multi-parameter chart view with graph icon
This commit is contained in:
@@ -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)
|
||||
|
||||
+96
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user