initial commit

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2026-01-11 15:43:53 +01:00
commit 953a6739c1
Se han modificado 63 ficheros con 4144 adiciones y 0 borrados

Ver fichero

@@ -0,0 +1,24 @@
package com.manalejandro.odb2bluetooth
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.manalejandro.odb2bluetooth", appContext.packageName)
}
}

Ver fichero

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Bluetooth permissions for Android 11 and below -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- Bluetooth permissions for Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ODB2Bluetooth">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.ODB2Bluetooth">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

Archivo binario no mostrado.

Ver fichero

@@ -0,0 +1,97 @@
package com.manalejandro.odb2bluetooth
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.manalejandro.odb2bluetooth.ui.navigation.Screen
import com.manalejandro.odb2bluetooth.ui.screens.*
import com.manalejandro.odb2bluetooth.ui.theme.ODB2BluetoothTheme
import com.manalejandro.odb2bluetooth.ui.viewmodel.MainViewModel
/**
* Main Activity - Entry point of the OBD2 Bluetooth application
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ODB2BluetoothTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
OBD2App()
}
}
}
}
}
@Composable
fun OBD2App() {
val navController = rememberNavController()
val mainViewModel: MainViewModel = viewModel()
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
HomeScreen(
viewModel = mainViewModel,
onNavigateToBluetooth = {
navController.navigate(Screen.Bluetooth.route)
},
onNavigateToVehicleSelection = {
navController.navigate(Screen.VehicleSelection.route)
},
onNavigateToDiagnostic = {
navController.navigate(Screen.Diagnostic.route)
},
onNavigateToCustomCommand = {
navController.navigate(Screen.CustomCommand.route)
}
)
}
composable(Screen.Bluetooth.route) {
BluetoothScreen(
viewModel = mainViewModel,
onNavigateBack = { navController.popBackStack() }
)
}
composable(Screen.VehicleSelection.route) {
VehicleSelectionScreen(
mainViewModel = mainViewModel,
onNavigateBack = { navController.popBackStack() }
)
}
composable(Screen.Diagnostic.route) {
DiagnosticScreen(
viewModel = mainViewModel,
onNavigateBack = { navController.popBackStack() }
)
}
composable(Screen.CustomCommand.route) {
CustomCommandScreen(
viewModel = mainViewModel,
onNavigateBack = { navController.popBackStack() }
)
}
}
}

Ver fichero

@@ -0,0 +1,202 @@
package com.manalejandro.odb2bluetooth.data.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
/**
* Service to manage Bluetooth connection with ELM327 OBD2 adapter
*/
class BluetoothService {
private var bluetoothSocket: BluetoothSocket? = null
private var inputStream: InputStream? = null
private var outputStream: OutputStream? = null
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState
private val _lastResponse = MutableStateFlow("")
val lastResponse: StateFlow<String> = _lastResponse
companion object {
private const val TAG = "BluetoothService"
// Standard SPP UUID for Bluetooth Serial
private val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
}
enum class ConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
ERROR
}
/**
* Get list of paired Bluetooth devices
*/
@SuppressLint("MissingPermission")
fun getPairedDevices(): List<BluetoothDevice> {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
return bluetoothAdapter?.bondedDevices?.toList() ?: emptyList()
}
/**
* Connect to a Bluetooth device
*/
@SuppressLint("MissingPermission")
suspend fun connect(device: BluetoothDevice): Result<Unit> = withContext(Dispatchers.IO) {
try {
_connectionState.value = ConnectionState.CONNECTING
// Close any existing connection
disconnect()
// Create socket and connect
bluetoothSocket = device.createRfcommSocketToServiceRecord(SPP_UUID)
bluetoothSocket?.connect()
inputStream = bluetoothSocket?.inputStream
outputStream = bluetoothSocket?.outputStream
// Initialize ELM327
initializeElm327()
_connectionState.value = ConnectionState.CONNECTED
Log.d(TAG, "Connected to ${device.name}")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Connection error", e)
_connectionState.value = ConnectionState.ERROR
disconnect()
Result.failure(e)
}
}
/**
* Initialize ELM327 with default commands
*/
private suspend fun initializeElm327() {
// Reset device
sendCommand("ATZ")
kotlinx.coroutines.delay(1000)
// Turn off echo
sendCommand("ATE0")
kotlinx.coroutines.delay(100)
// Set line feed off
sendCommand("ATL0")
kotlinx.coroutines.delay(100)
// Set spaces off
sendCommand("ATS0")
kotlinx.coroutines.delay(100)
// Set protocol to automatic
sendCommand("ATSP0")
kotlinx.coroutines.delay(100)
}
/**
* Send a command to the ELM327 device
*/
suspend fun sendCommand(command: String): Result<String> = withContext(Dispatchers.IO) {
if (_connectionState.value != ConnectionState.CONNECTED) {
return@withContext Result.failure(IOException("Not connected"))
}
try {
val commandWithCR = "$command\r"
outputStream?.write(commandWithCR.toByteArray())
outputStream?.flush()
Log.d(TAG, "Sent: $command")
// Read response
val response = readResponse()
_lastResponse.value = response
Log.d(TAG, "Received: $response")
Result.success(response)
} catch (e: Exception) {
Log.e(TAG, "Send command error", e)
Result.failure(e)
}
}
/**
* Read response from ELM327
*/
private fun readResponse(): String {
val buffer = ByteArray(1024)
val response = StringBuilder()
var bytesRead: Int
try {
val startTime = System.currentTimeMillis()
val timeout = 5000 // 5 seconds timeout
while (System.currentTimeMillis() - startTime < timeout) {
if (inputStream?.available() ?: 0 > 0) {
bytesRead = inputStream?.read(buffer) ?: 0
if (bytesRead > 0) {
val chunk = String(buffer, 0, bytesRead)
response.append(chunk)
// Check for prompt character '>'
if (chunk.contains('>')) {
break
}
}
}
Thread.sleep(10)
}
} catch (e: Exception) {
Log.e(TAG, "Read response error", e)
}
return response.toString()
.replace(">", "")
.replace("\r", "")
.replace("\n", "")
.trim()
}
/**
* Disconnect from the Bluetooth device
*/
fun disconnect() {
try {
inputStream?.close()
outputStream?.close()
bluetoothSocket?.close()
} catch (e: Exception) {
Log.e(TAG, "Disconnect error", e)
} finally {
inputStream = null
outputStream = null
bluetoothSocket = null
_connectionState.value = ConnectionState.DISCONNECTED
}
}
/**
* Check if Bluetooth is available and enabled
*/
fun isBluetoothAvailable(): Boolean {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
return bluetoothAdapter != null && bluetoothAdapter.isEnabled
}
}

Ver fichero

@@ -0,0 +1,56 @@
package com.manalejandro.odb2bluetooth.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.manalejandro.odb2bluetooth.data.database.dao.DtcCodeDao
import com.manalejandro.odb2bluetooth.data.database.dao.ObdSignalDao
import com.manalejandro.odb2bluetooth.data.database.dao.VehicleDao
import com.manalejandro.odb2bluetooth.data.database.entities.Brand
import com.manalejandro.odb2bluetooth.data.database.entities.DtcCode
import com.manalejandro.odb2bluetooth.data.database.entities.Generation
import com.manalejandro.odb2bluetooth.data.database.entities.Model
import com.manalejandro.odb2bluetooth.data.database.entities.ObdSignal
/**
* Main Room database for the OBD2 application
*/
@Database(
entities = [
Brand::class,
Model::class,
Generation::class,
ObdSignal::class,
DtcCode::class
],
version = 1,
exportSchema = false
)
abstract class ObdDatabase : RoomDatabase() {
abstract fun vehicleDao(): VehicleDao
abstract fun obdSignalDao(): ObdSignalDao
abstract fun dtcCodeDao(): DtcCodeDao
companion object {
@Volatile
private var INSTANCE: ObdDatabase? = null
fun getDatabase(context: Context): ObdDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
ObdDatabase::class.java,
"obd_database"
)
.createFromAsset("obd_database.db")
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}

Ver fichero

@@ -0,0 +1,28 @@
package com.manalejandro.odb2bluetooth.data.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.manalejandro.odb2bluetooth.data.database.entities.DtcCode
import kotlinx.coroutines.flow.Flow
/**
* Data Access Object for DTC (Diagnostic Trouble Codes) queries
*/
@Dao
interface DtcCodeDao {
@Query("SELECT * FROM dtc_codes ORDER BY code")
fun getAllDtcCodes(): Flow<List<DtcCode>>
@Query("SELECT * FROM dtc_codes WHERE code = :code")
suspend fun getDtcCodeByCode(code: String): DtcCode?
@Query("""
SELECT * FROM dtc_codes
WHERE code LIKE '%' || :query || '%'
OR description LIKE '%' || :query || '%'
ORDER BY code
""")
fun searchDtcCodes(query: String): Flow<List<DtcCode>>
}

Ver fichero

@@ -0,0 +1,50 @@
package com.manalejandro.odb2bluetooth.data.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.manalejandro.odb2bluetooth.data.database.dto.SignalDto
import com.manalejandro.odb2bluetooth.data.database.entities.ObdSignal
import kotlinx.coroutines.flow.Flow
/**
* Data Access Object for OBD signals queries
*/
@Dao
interface ObdSignalDao {
@Query("SELECT * FROM obd_signals WHERE model_id = :modelId ORDER BY signal_id")
fun getSignalsByModel(modelId: Int): Flow<List<ObdSignal>>
@Query("""
SELECT
s.id,
b.name as brand,
m.name as model,
s.signal_id as signalId,
s.name as signalName,
s.path,
s.unit,
s.header,
s.command,
s.frequency,
s.description
FROM obd_signals s
JOIN models m ON s.model_id = m.id
JOIN brands b ON m.brand_id = b.id
WHERE s.model_id = :modelId
ORDER BY s.signal_id
""")
fun getSignalsDtoByModel(modelId: Int): Flow<List<SignalDto>>
@Query("SELECT * FROM obd_signals WHERE id = :signalId")
suspend fun getSignalById(signalId: Int): ObdSignal?
@Query("""
SELECT * FROM obd_signals
WHERE model_id = :modelId
AND (name LIKE '%' || :query || '%' OR signal_id LIKE '%' || :query || '%')
ORDER BY signal_id
""")
fun searchSignals(modelId: Int, query: String): Flow<List<ObdSignal>>
}

Ver fichero

@@ -0,0 +1,59 @@
package com.manalejandro.odb2bluetooth.data.database.dao
import androidx.room.Dao
import androidx.room.Query
import com.manalejandro.odb2bluetooth.data.database.dto.VehicleDto
import com.manalejandro.odb2bluetooth.data.database.entities.Brand
import com.manalejandro.odb2bluetooth.data.database.entities.Model
import kotlinx.coroutines.flow.Flow
/**
* Data Access Object for vehicle-related queries (brands and models)
*/
@Dao
interface VehicleDao {
@Query("SELECT * FROM brands ORDER BY name")
fun getAllBrands(): Flow<List<Brand>>
@Query("SELECT * FROM models WHERE brand_id = :brandId ORDER BY name")
fun getModelsByBrand(brandId: Int): Flow<List<Model>>
@Query("""
SELECT
m.id as modelId,
b.name as brand,
m.name as model,
m.full_name as fullName,
COUNT(DISTINCT g.id) as generationCount,
COUNT(DISTINCT s.id) as signalCount
FROM models m
JOIN brands b ON m.brand_id = b.id
LEFT JOIN generations g ON m.id = g.model_id
LEFT JOIN obd_signals s ON m.id = s.model_id
GROUP BY m.id, b.name, m.name, m.full_name
ORDER BY b.name, m.name
""")
fun getAllVehicles(): Flow<List<VehicleDto>>
@Query("""
SELECT
m.id as modelId,
b.name as brand,
m.name as model,
m.full_name as fullName,
COUNT(DISTINCT g.id) as generationCount,
COUNT(DISTINCT s.id) as signalCount
FROM models m
JOIN brands b ON m.brand_id = b.id
LEFT JOIN generations g ON m.id = g.model_id
LEFT JOIN obd_signals s ON m.id = s.model_id
WHERE b.name LIKE '%' || :query || '%'
OR m.name LIKE '%' || :query || '%'
OR m.full_name LIKE '%' || :query || '%'
GROUP BY m.id, b.name, m.name, m.full_name
ORDER BY b.name, m.name
""")
fun searchVehicles(query: String): Flow<List<VehicleDto>>
}

Ver fichero

@@ -0,0 +1,19 @@
package com.manalejandro.odb2bluetooth.data.database.dto
/**
* Data Transfer Object for signal information including vehicle details
*/
data class SignalDto(
val id: Int,
val brand: String,
val model: String,
val signalId: String,
val signalName: String,
val path: String?,
val unit: String?,
val header: String?,
val command: String?,
val frequency: Double?,
val description: String?
)

Ver fichero

@@ -0,0 +1,14 @@
package com.manalejandro.odb2bluetooth.data.database.dto
/**
* Data Transfer Object for vehicle information combining brand and model data
*/
data class VehicleDto(
val modelId: Int,
val brand: String,
val model: String,
val fullName: String,
val generationCount: Int,
val signalCount: Int
)

Ver fichero

@@ -0,0 +1,15 @@
package com.manalejandro.odb2bluetooth.data.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Entity representing a vehicle brand in the database
*/
@Entity(tableName = "brands")
data class Brand(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val name: String
)

Ver fichero

@@ -0,0 +1,15 @@
package com.manalejandro.odb2bluetooth.data.database.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Entity representing a diagnostic trouble code (DTC) in the database
*/
@Entity(tableName = "dtc_codes")
data class DtcCode(
@PrimaryKey
val code: String,
val description: String
)

Ver fichero

@@ -0,0 +1,37 @@
package com.manalejandro.odb2bluetooth.data.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Entity representing a vehicle generation in the database
*/
@Entity(
tableName = "generations",
foreignKeys = [
ForeignKey(
entity = Model::class,
parentColumns = ["id"],
childColumns = ["model_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("model_id")]
)
data class Generation(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
@ColumnInfo(name = "model_id")
val modelId: Int,
val name: String,
val code: String?,
@ColumnInfo(name = "start_year")
val startYear: Int?,
@ColumnInfo(name = "end_year")
val endYear: Int?,
val description: String?
)

Ver fichero

@@ -0,0 +1,33 @@
package com.manalejandro.odb2bluetooth.data.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Entity representing a vehicle model in the database
*/
@Entity(
tableName = "models",
foreignKeys = [
ForeignKey(
entity = Brand::class,
parentColumns = ["id"],
childColumns = ["brand_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("brand_id")]
)
data class Model(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
@ColumnInfo(name = "brand_id")
val brandId: Int,
val name: String,
@ColumnInfo(name = "full_name")
val fullName: String
)

Ver fichero

@@ -0,0 +1,43 @@
package com.manalejandro.odb2bluetooth.data.database.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Entity representing an OBD signal (PID command) in the database
*/
@Entity(
tableName = "obd_signals",
foreignKeys = [
ForeignKey(
entity = Model::class,
parentColumns = ["id"],
childColumns = ["model_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("model_id")]
)
data class ObdSignal(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
@ColumnInfo(name = "model_id")
val modelId: Int,
@ColumnInfo(name = "signal_id")
val signalId: String,
val name: String,
val path: String?,
val unit: String?,
val description: String?,
val header: String?,
val command: String?,
val frequency: Double?,
@ColumnInfo(name = "min_value")
val minValue: Double?,
@ColumnInfo(name = "max_value")
val maxValue: Double?
)

Ver fichero

@@ -0,0 +1,160 @@
package com.manalejandro.odb2bluetooth.data.obd
/**
* Generic OBD2 commands for diagnostics
*/
object ObdCommands {
/**
* Generic safe diagnostic commands
*/
val SAFE_DIAGNOSTIC_COMMANDS = listOf(
ObdCommand(
name = "Vehicle Speed",
command = "010D",
description = "Current vehicle speed in km/h",
unit = "km/h",
isSafe = true
),
ObdCommand(
name = "Engine RPM",
command = "010C",
description = "Engine revolutions per minute",
unit = "RPM",
isSafe = true
),
ObdCommand(
name = "Coolant Temperature",
command = "0105",
description = "Engine coolant temperature",
unit = "°C",
isSafe = true
),
ObdCommand(
name = "Throttle Position",
command = "0111",
description = "Throttle position percentage",
unit = "%",
isSafe = true
),
ObdCommand(
name = "Fuel Level",
command = "012F",
description = "Fuel tank level input",
unit = "%",
isSafe = true
),
ObdCommand(
name = "Intake Air Temperature",
command = "010F",
description = "Intake air temperature",
unit = "°C",
isSafe = true
),
ObdCommand(
name = "MAF Air Flow Rate",
command = "0110",
description = "Mass air flow sensor air flow rate",
unit = "g/s",
isSafe = true
),
ObdCommand(
name = "Engine Load",
command = "0104",
description = "Calculated engine load value",
unit = "%",
isSafe = true
)
)
/**
* DTC (Diagnostic Trouble Codes) commands
*/
val DTC_COMMANDS = listOf(
ObdCommand(
name = "Read DTCs",
command = "03",
description = "Request diagnostic trouble codes",
unit = "",
isSafe = true
),
ObdCommand(
name = "Clear DTCs",
command = "04",
description = "Clear diagnostic trouble codes and stored values",
unit = "",
isSafe = false
),
ObdCommand(
name = "Pending DTCs",
command = "07",
description = "Request pending diagnostic trouble codes",
unit = "",
isSafe = true
)
)
/**
* Parse OBD2 response for common PIDs
*/
fun parseResponse(command: String, response: String): String? {
if (response.contains("NO DATA") || response.contains("ERROR")) {
return null
}
// Remove spaces and common prefixes
val cleanResponse = response.replace(" ", "").replace("41", "").replace("43", "")
return when (command) {
"010C" -> { // RPM
try {
val value = cleanResponse.substring(2, 6).toInt(16)
"${value / 4} RPM"
} catch (e: Exception) { null }
}
"010D" -> { // Speed
try {
val value = cleanResponse.substring(2, 4).toInt(16)
"$value km/h"
} catch (e: Exception) { null }
}
"0105" -> { // Coolant temp
try {
val value = cleanResponse.substring(2, 4).toInt(16) - 40
"$value °C"
} catch (e: Exception) { null }
}
"010F" -> { // Intake air temp
try {
val value = cleanResponse.substring(2, 4).toInt(16) - 40
"$value °C"
} catch (e: Exception) { null }
}
"0111", "0104", "012F" -> { // Percentage values
try {
val value = (cleanResponse.substring(2, 4).toInt(16) * 100) / 255
"$value %"
} catch (e: Exception) { null }
}
"0110" -> { // MAF
try {
val value = cleanResponse.substring(2, 6).toInt(16) / 100.0
String.format("%.2f g/s", value)
} catch (e: Exception) { null }
}
else -> cleanResponse
}
}
}
/**
* Data class representing an OBD command
*/
data class ObdCommand(
val name: String,
val command: String,
val description: String,
val unit: String,
val isSafe: Boolean
)

Ver fichero

@@ -0,0 +1,48 @@
package com.manalejandro.odb2bluetooth.data.repository
import com.manalejandro.odb2bluetooth.data.database.dao.DtcCodeDao
import com.manalejandro.odb2bluetooth.data.database.dao.ObdSignalDao
import com.manalejandro.odb2bluetooth.data.database.dao.VehicleDao
import com.manalejandro.odb2bluetooth.data.database.dto.SignalDto
import com.manalejandro.odb2bluetooth.data.database.dto.VehicleDto
import com.manalejandro.odb2bluetooth.data.database.entities.Brand
import com.manalejandro.odb2bluetooth.data.database.entities.DtcCode
import com.manalejandro.odb2bluetooth.data.database.entities.ObdSignal
import kotlinx.coroutines.flow.Flow
/**
* Repository for OBD database operations
*/
class ObdRepository(
private val vehicleDao: VehicleDao,
private val obdSignalDao: ObdSignalDao,
private val dtcCodeDao: DtcCodeDao
) {
// Vehicle operations
fun getAllVehicles(): Flow<List<VehicleDto>> = vehicleDao.getAllVehicles()
fun searchVehicles(query: String): Flow<List<VehicleDto>> = vehicleDao.searchVehicles(query)
fun getAllBrands(): Flow<List<Brand>> = vehicleDao.getAllBrands()
// Signal operations
fun getSignalsByModel(modelId: Int): Flow<List<SignalDto>> =
obdSignalDao.getSignalsDtoByModel(modelId)
suspend fun getSignalById(signalId: Int): ObdSignal? =
obdSignalDao.getSignalById(signalId)
fun searchSignals(modelId: Int, query: String): Flow<List<ObdSignal>> =
obdSignalDao.searchSignals(modelId, query)
// DTC operations
fun getAllDtcCodes(): Flow<List<DtcCode>> = dtcCodeDao.getAllDtcCodes()
suspend fun getDtcCodeByCode(code: String): DtcCode? =
dtcCodeDao.getDtcCodeByCode(code)
fun searchDtcCodes(query: String): Flow<List<DtcCode>> =
dtcCodeDao.searchDtcCodes(query)
}

Ver fichero

@@ -0,0 +1,148 @@
package com.manalejandro.odb2bluetooth.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Warning dialog to inform users about potential risks when sending OBD commands
*/
@Composable
fun WarningDialog(
onDismiss: () -> Unit,
onAccept: () -> Unit,
title: String = "Warning",
message: String
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "Warning",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp)
)
},
title = {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Justify
)
},
confirmButton = {
Button(
onClick = onAccept,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("I Understand - Continue")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
/**
* Connection status indicator
*/
@Composable
fun ConnectionStatusIndicator(
isConnected: Boolean,
deviceName: String?,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
color = if (isConnected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Status dot
Surface(
modifier = Modifier.size(12.dp),
shape = MaterialTheme.shapes.extraLarge,
color = if (isConnected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
) {}
Text(
text = if (isConnected) {
"Connected${deviceName?.let { " to $it" } ?: ""}"
} else {
"Disconnected"
},
style = MaterialTheme.typography.labelLarge,
color = if (isConnected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
/**
* Loading overlay
*/
@Composable
fun LoadingOverlay(
isLoading: Boolean,
message: String = "Loading..."
) {
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator()
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}

Ver fichero

@@ -0,0 +1,14 @@
package com.manalejandro.odb2bluetooth.ui.navigation
/**
* Navigation routes for the app
*/
sealed class Screen(val route: String) {
object Home : Screen("home")
object Bluetooth : Screen("bluetooth")
object VehicleSelection : Screen("vehicle_selection")
object Diagnostic : Screen("diagnostic")
object CustomCommand : Screen("custom_command")
object About : Screen("about")
}

Ver fichero

@@ -0,0 +1,314 @@
package com.manalejandro.odb2bluetooth.ui.screens
import android.Manifest
import android.bluetooth.BluetoothDevice
import android.os.Build
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.manalejandro.odb2bluetooth.data.bluetooth.BluetoothService
import com.manalejandro.odb2bluetooth.ui.components.ConnectionStatusIndicator
import com.manalejandro.odb2bluetooth.ui.viewmodel.MainViewModel
/**
* Bluetooth screen - Device pairing and connection
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable
fun BluetoothScreen(
viewModel: MainViewModel,
onNavigateBack: () -> Unit
) {
val connectionState by viewModel.bluetoothService.connectionState.collectAsState()
val pairedDevices by viewModel.pairedDevices.collectAsState()
val selectedDevice by viewModel.selectedDevice.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
val isConnected = connectionState == BluetoothService.ConnectionState.CONNECTED
// Handle Bluetooth permissions
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN
)
} else {
listOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN
)
}
val permissionsState = rememberMultiplePermissionsState(bluetoothPermissions)
LaunchedEffect(Unit) {
if (!permissionsState.allPermissionsGranted) {
permissionsState.launchMultiplePermissionRequest()
} else {
viewModel.loadPairedDevices()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Bluetooth Connection") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (!permissionsState.allPermissionsGranted) {
// Permission request UI
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Phone,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Bluetooth Permission Required",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This app needs Bluetooth permission to connect to OBD2 devices.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { permissionsState.launchMultiplePermissionRequest() }) {
Text("Grant Permission")
}
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Connection status
ConnectionStatusIndicator(
isConnected = isConnected,
deviceName = selectedDevice?.name,
modifier = Modifier.fillMaxWidth()
)
// Connection controls
if (isConnected) {
ElevatedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Connected Device",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = selectedDevice?.name ?: "Unknown",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = selectedDevice?.address ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.disconnect() },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Close, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Disconnect")
}
}
}
}
// Error message
errorMessage?.let { error ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
// Paired devices list
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Paired Devices",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
IconButton(onClick = { viewModel.loadPairedDevices() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
if (pairedDevices.isEmpty()) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No paired devices found",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Pair your ELM327 device in system settings",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(pairedDevices) { device ->
DeviceCard(
device = device,
isConnected = device.address == selectedDevice?.address && isConnected,
isLoading = isLoading && device.address == selectedDevice?.address,
onConnect = { viewModel.connectToDevice(device) }
)
}
}
}
}
}
}
}
}
@Composable
private fun DeviceCard(
device: BluetoothDevice,
isConnected: Boolean,
isLoading: Boolean,
onConnect: () -> Unit
) {
ElevatedCard(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = device.name ?: "Unknown Device",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = device.address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp)
)
} else if (isConnected) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = "Connected",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
} else {
Button(onClick = onConnect) {
Text("Connect")
}
}
}
}
}

Ver fichero

@@ -0,0 +1,358 @@
package com.manalejandro.odb2bluetooth.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.manalejandro.odb2bluetooth.ui.components.WarningDialog
import com.manalejandro.odb2bluetooth.ui.viewmodel.CommandResult
import com.manalejandro.odb2bluetooth.ui.viewmodel.MainViewModel
import java.text.SimpleDateFormat
import java.util.*
/**
* Custom command screen - Send custom OBD2 commands
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomCommandScreen(
viewModel: MainViewModel,
onNavigateBack: () -> Unit
) {
var commandText by remember { mutableStateOf("") }
var editedCommand by remember { mutableStateOf("") }
var showWarning by remember { mutableStateOf(false) }
val commandHistory by viewModel.commandHistory.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
if (showWarning) {
WarningDialog(
onDismiss = {
showWarning = false
editedCommand = ""
},
onAccept = {
showWarning = false
viewModel.sendCommand(editedCommand)
commandText = ""
editedCommand = ""
},
title = "⚠️ DANGER - Read Carefully",
message = "YOU ARE ABOUT TO SEND A CUSTOM COMMAND TO YOUR VEHICLE.\n\n" +
"Command: $editedCommand\n\n" +
"⚠️ WARNING:\n" +
"• Sending incorrect commands can DAMAGE your vehicle\n" +
"• You are FULLY RESPONSIBLE for any consequences\n" +
"• This action may VOID your warranty\n" +
"• Only proceed if you KNOW what this command does\n\n" +
"Do you accept ALL RISKS and want to proceed?"
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Custom Commands") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.clearHistory() }) {
Icon(Icons.Default.Delete, contentDescription = "Clear history")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Warning card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Column {
Text(
text = "⚠️ DANGER ZONE",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = "Custom commands can damage your vehicle. Use ONLY if you know what you're doing. YOU are responsible for any consequences.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
// Command input
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = commandText,
onValueChange = { commandText = it.uppercase() },
label = { Text("OBD2 Command") },
placeholder = { Text("e.g., 010C, ATZ, 0105") },
leadingIcon = {
Icon(Icons.Default.Settings, contentDescription = null)
},
trailingIcon = {
if (commandText.isNotEmpty()) {
IconButton(onClick = { commandText = "" }) {
Icon(Icons.Default.Clear, contentDescription = "Clear")
}
}
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Characters,
imeAction = ImeAction.Send
),
keyboardActions = KeyboardActions(
onSend = {
if (commandText.isNotBlank()) {
editedCommand = commandText
showWarning = true
}
}
),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
if (commandText.isNotBlank()) {
editedCommand = commandText
showWarning = true
}
},
modifier = Modifier.fillMaxWidth(),
enabled = commandText.isNotBlank() && !isLoading,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Send, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Send Command")
}
}
Spacer(modifier = Modifier.height(16.dp))
// Loading indicator
if (isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
// Common commands
Text(
text = "Common Commands",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf("010C", "010D", "0105", "ATZ").forEach { cmd ->
FilterChip(
selected = false,
onClick = { commandText = cmd },
label = { Text(cmd) }
)
}
}
Divider(modifier = Modifier.padding(vertical = 8.dp))
// Command history
Text(
text = "Response History",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp)
)
if (commandHistory.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "No commands sent yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(commandHistory) { result ->
CommandResultCard(
result = result,
onResend = {
commandText = result.command
}
)
}
}
}
}
}
}
@Composable
private fun CommandResultCard(
result: CommandResult,
onResend: () -> Unit
) {
val dateFormat = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
val isError = result.parsedResponse.contains("Error", ignoreCase = true) ||
result.rawResponse.contains("NO DATA", ignoreCase = true) ||
result.rawResponse.contains("ERROR", ignoreCase = true)
ElevatedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isError) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
Text(
text = result.command,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
IconButton(
onClick = onResend,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Resend",
modifier = Modifier.size(20.dp)
)
}
}
Divider()
Text(
text = "Response:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Text(
text = result.rawResponse.ifBlank { "No response" },
style = MaterialTheme.typography.bodyMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
modifier = Modifier.padding(8.dp)
)
}
if (result.parsedResponse != result.rawResponse && result.parsedResponse.isNotBlank()) {
Text(
text = "Parsed: ${result.parsedResponse}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (isError)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.primary
)
}
Text(
text = dateFormat.format(Date(result.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

Ver fichero

@@ -0,0 +1,266 @@
package com.manalejandro.odb2bluetooth.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.manalejandro.odb2bluetooth.data.obd.ObdCommands
import com.manalejandro.odb2bluetooth.ui.components.WarningDialog
import com.manalejandro.odb2bluetooth.ui.viewmodel.CommandResult
import com.manalejandro.odb2bluetooth.ui.viewmodel.MainViewModel
import java.text.SimpleDateFormat
import java.util.*
/**
* Diagnostic screen - Run safe diagnostic tests
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiagnosticScreen(
viewModel: MainViewModel,
onNavigateBack: () -> Unit
) {
val commandHistory by viewModel.commandHistory.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
var showWarning by remember { mutableStateOf(false) }
if (showWarning) {
WarningDialog(
onDismiss = { showWarning = false },
onAccept = {
showWarning = false
viewModel.runDiagnosticTest()
},
title = "Diagnostic Test",
message = "This will run a series of safe, read-only diagnostic commands to gather information from your vehicle. No modifications will be made to your vehicle's systems.\n\nThe test will read:\n• Engine RPM\n• Vehicle Speed\n• Coolant Temperature\n• Throttle Position\n• Fuel Level\n• And more...\n\nDo you want to continue?"
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Quick Diagnostic") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.clearHistory() }) {
Icon(Icons.Default.Delete, contentDescription = "Clear history")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
},
floatingActionButton = {
if (!isLoading) {
ExtendedFloatingActionButton(
onClick = { showWarning = true },
icon = { Icon(Icons.Default.PlayArrow, contentDescription = null) },
text = { Text("Run Test") }
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Info card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column {
Text(
text = "Safe Diagnostic Test",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "This test only reads data from your vehicle and will not make any modifications.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
// Loading indicator
if (isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
// Command history
if (commandHistory.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "No diagnostic data yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Tap 'Run Test' to start",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(commandHistory) { result ->
DiagnosticResultCard(result)
}
}
}
}
}
}
@Composable
private fun DiagnosticResultCard(result: CommandResult) {
val dateFormat = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
val commandName = ObdCommands.SAFE_DIAGNOSTIC_COMMANDS
.find { it.command == result.command }?.name ?: result.command
val isError = result.parsedResponse.contains("Error", ignoreCase = true) ||
result.parsedResponse.contains("NO DATA", ignoreCase = true)
ElevatedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = commandName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
if (isError) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
} else {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
}
Divider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Command:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = result.command,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Response:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = result.parsedResponse,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = if (isError)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.primary
)
}
if (result.rawResponse.isNotBlank()) {
Text(
text = "Raw: ${result.rawResponse}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
Text(
text = dateFormat.format(Date(result.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

Ver fichero

@@ -0,0 +1,231 @@
package com.manalejandro.odb2bluetooth.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.manalejandro.odb2bluetooth.data.bluetooth.BluetoothService
import com.manalejandro.odb2bluetooth.ui.components.ConnectionStatusIndicator
import com.manalejandro.odb2bluetooth.ui.viewmodel.MainViewModel
/**
* Home screen - Main dashboard
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
viewModel: MainViewModel,
onNavigateToBluetooth: () -> Unit,
onNavigateToVehicleSelection: () -> Unit,
onNavigateToDiagnostic: () -> Unit,
onNavigateToCustomCommand: () -> Unit
) {
val connectionState by viewModel.bluetoothService.connectionState.collectAsState()
val selectedDevice by viewModel.selectedDevice.collectAsState()
val isConnected = connectionState == BluetoothService.ConnectionState.CONNECTED
Scaffold(
topBar = {
TopAppBar(
title = { Text("OBD2 Bluetooth") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Connection status
ConnectionStatusIndicator(
isConnected = isConnected,
deviceName = selectedDevice?.name,
modifier = Modifier.fillMaxWidth()
)
// Welcome card
ElevatedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Welcome to OBD2 Bluetooth",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "Connect to your vehicle's ELM327 adapter to read and send OBD2 commands.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = "Quick Actions",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp)
)
// Bluetooth connection button
MenuCard(
title = "Bluetooth Connection",
description = if (isConnected)
"Manage connection"
else
"Connect to OBD2 device",
icon = Icons.Default.Phone,
onClick = onNavigateToBluetooth
)
// Vehicle selection button
MenuCard(
title = "Vehicle Database",
description = "Browse OBD signals by vehicle model",
icon = Icons.Default.Build,
onClick = onNavigateToVehicleSelection,
enabled = isConnected
)
// Diagnostic test button
MenuCard(
title = "Quick Diagnostic",
description = "Run safe diagnostic test",
icon = Icons.Default.Star,
onClick = onNavigateToDiagnostic,
enabled = isConnected
)
// Custom command button
MenuCard(
title = "Custom Commands",
description = "Send custom OBD2 commands",
icon = Icons.Default.Settings,
onClick = onNavigateToCustomCommand,
enabled = isConnected
)
// Safety notice
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Column {
Text(
text = "Safety Notice",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = "Sending incorrect commands can damage your vehicle. Use at your own risk.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
}
@Composable
private fun MenuCard(
title: String,
description: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
enabled: Boolean = true
) {
ElevatedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
enabled = enabled
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = if (enabled)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(56.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = if (enabled)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.Default.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

Ver fichero

@@ -0,0 +1,414 @@
package com.manalejandro.odb2bluetooth.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.manalejandro.odb2bluetooth.data.database.dto.SignalDto
import com.manalejandro.odb2bluetooth.data.database.dto.VehicleDto
import com.manalejandro.odb2bluetooth.ui.components.WarningDialog
import com.manalejandro.odb2bluetooth.ui.viewmodel.MainViewModel
import com.manalejandro.odb2bluetooth.ui.viewmodel.VehicleViewModel
/**
* Vehicle selection screen - Browse vehicles and their OBD signals
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VehicleSelectionScreen(
mainViewModel: MainViewModel,
onNavigateBack: () -> Unit
) {
val vehicleViewModel: VehicleViewModel = viewModel()
val vehicles by vehicleViewModel.vehicles.collectAsState()
val selectedVehicle by vehicleViewModel.selectedVehicle.collectAsState()
val signals by vehicleViewModel.signals.collectAsState()
val searchQuery by vehicleViewModel.searchQuery.collectAsState()
val isLoading by vehicleViewModel.isLoading.collectAsState()
var searchText by remember { mutableStateOf("") }
var showWarning by remember { mutableStateOf(false) }
var selectedSignal by remember { mutableStateOf<SignalDto?>(null) }
if (showWarning && selectedSignal != null) {
WarningDialog(
onDismiss = {
showWarning = false
selectedSignal = null
},
onAccept = {
showWarning = false
selectedSignal?.command?.let { command ->
mainViewModel.sendCommand(command)
}
selectedSignal = null
},
title = "Send OBD Command",
message = "You are about to send this command:\n\n" +
"Signal: ${selectedSignal?.signalName}\n" +
"Command: ${selectedSignal?.command}\n" +
"Description: ${selectedSignal?.description ?: "N/A"}\n\n" +
"⚠️ Warning: Sending commands can potentially affect your vehicle. " +
"Make sure you understand what this command does.\n\n" +
"Do you want to proceed?"
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Vehicle Database") },
navigationIcon = {
IconButton(onClick = {
if (selectedVehicle != null) {
vehicleViewModel.clearSelection()
} else {
onNavigateBack()
}
}) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (selectedVehicle == null) {
// Vehicle list view
Column(
modifier = Modifier.fillMaxSize()
) {
// Search bar
OutlinedTextField(
value = searchText,
onValueChange = {
searchText = it
vehicleViewModel.searchVehicles(it)
},
label = { Text("Search vehicles") },
placeholder = { Text("Brand, model...") },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
trailingIcon = {
if (searchText.isNotEmpty()) {
IconButton(onClick = {
searchText = ""
vehicleViewModel.searchVehicles("")
}) {
Icon(Icons.Default.Clear, contentDescription = "Clear")
}
}
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
if (isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
if (vehicles.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Build,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "No vehicles found",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(vehicles) { vehicle ->
VehicleCard(
vehicle = vehicle,
onClick = { vehicleViewModel.selectVehicle(vehicle) }
)
}
}
}
}
} else {
// Signal list view
Column(
modifier = Modifier.fillMaxSize()
) {
// Vehicle info
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = selectedVehicle!!.brand,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
text = selectedVehicle!!.fullName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "${selectedVehicle!!.signalCount} OBD signals available",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
if (signals.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "No signals available",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(signals) { signal ->
SignalCard(
signal = signal,
onSend = {
selectedSignal = signal
showWarning = true
}
)
}
}
}
}
}
}
}
}
@Composable
private fun VehicleCard(
vehicle: VehicleDto,
onClick: () -> Unit
) {
ElevatedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(56.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Icon(
imageVector = Icons.Default.Build,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = vehicle.brand,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = vehicle.model,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = "${vehicle.signalCount} signals • ${vehicle.generationCount} generations",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.Default.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun SignalCard(
signal: SignalDto,
onSend: () -> Unit
) {
ElevatedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = signal.signalName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = signal.signalId,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
Button(
onClick = onSend,
enabled = signal.command != null
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
}
if (signal.description != null) {
Text(
text = signal.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Divider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
signal.command?.let {
InfoChip("Command", it)
}
signal.unit?.let {
InfoChip("Unit", it)
}
signal.frequency?.let {
InfoChip("Freq", "${it}Hz")
}
}
}
}
}
@Composable
private fun InfoChip(label: String, value: String) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "$label:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = value,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}

Ver fichero

@@ -0,0 +1,19 @@
package com.manalejandro.odb2bluetooth.ui.theme
import androidx.compose.ui.graphics.Color
// Light theme colors - Modern tech/automotive inspired
val Blue80 = Color(0xFF80C7FF)
val BlueGrey80 = Color(0xFFB0C7D6)
val Teal80 = Color(0xFF76D9D9)
val Blue40 = Color(0xFF0066CC)
val BlueGrey40 = Color(0xFF546E7A)
val Teal40 = Color(0xFF00897B)
// Additional custom colors
val Success = Color(0xFF4CAF50)
val Warning = Color(0xFFFF9800)
val Error = Color(0xFFF44336)
val Info = Color(0xFF2196F3)

Ver fichero

@@ -0,0 +1,60 @@
package com.manalejandro.odb2bluetooth.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Blue80,
secondary = BlueGrey80,
tertiary = Teal80,
error = Error
)
private val LightColorScheme = lightColorScheme(
primary = Blue40,
secondary = BlueGrey40,
tertiary = Teal40,
error = Error
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun ODB2BluetoothTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

Ver fichero

@@ -0,0 +1,34 @@
package com.manalejandro.odb2bluetooth.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

Ver fichero

@@ -0,0 +1,168 @@
package com.manalejandro.odb2bluetooth.ui.viewmodel
import android.app.Application
import android.bluetooth.BluetoothDevice
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.odb2bluetooth.data.bluetooth.BluetoothService
import com.manalejandro.odb2bluetooth.data.database.ObdDatabase
import com.manalejandro.odb2bluetooth.data.obd.ObdCommand
import com.manalejandro.odb2bluetooth.data.obd.ObdCommands
import com.manalejandro.odb2bluetooth.data.repository.ObdRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Main ViewModel for Bluetooth connection and OBD communication
*/
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val database = ObdDatabase.getDatabase(application)
val repository = ObdRepository(
database.vehicleDao(),
database.obdSignalDao(),
database.dtcCodeDao()
)
val bluetoothService = BluetoothService()
private val _pairedDevices = MutableStateFlow<List<BluetoothDevice>>(emptyList())
val pairedDevices: StateFlow<List<BluetoothDevice>> = _pairedDevices
private val _selectedDevice = MutableStateFlow<BluetoothDevice?>(null)
val selectedDevice: StateFlow<BluetoothDevice?> = _selectedDevice
private val _commandHistory = MutableStateFlow<List<CommandResult>>(emptyList())
val commandHistory: StateFlow<List<CommandResult>> = _commandHistory
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage
/**
* Load paired Bluetooth devices
*/
fun loadPairedDevices() {
try {
_pairedDevices.value = bluetoothService.getPairedDevices()
} catch (e: SecurityException) {
_errorMessage.value = "Bluetooth permission denied"
}
}
/**
* Connect to a Bluetooth device
*/
fun connectToDevice(device: BluetoothDevice) {
viewModelScope.launch {
_isLoading.value = true
_selectedDevice.value = device
bluetoothService.connect(device).fold(
onSuccess = {
_errorMessage.value = null
},
onFailure = { error ->
_errorMessage.value = "Connection failed: ${error.message}"
_selectedDevice.value = null
}
)
_isLoading.value = false
}
}
/**
* Disconnect from current device
*/
fun disconnect() {
bluetoothService.disconnect()
_selectedDevice.value = null
_commandHistory.value = emptyList()
}
/**
* Send a command to the OBD device
*/
fun sendCommand(command: String) {
viewModelScope.launch {
_isLoading.value = true
bluetoothService.sendCommand(command).fold(
onSuccess = { response ->
val parsed = ObdCommands.parseResponse(command, response) ?: response
addCommandToHistory(command, response, parsed)
_errorMessage.value = null
},
onFailure = { error ->
_errorMessage.value = "Command failed: ${error.message}"
addCommandToHistory(command, "", "Error: ${error.message}")
}
)
_isLoading.value = false
}
}
/**
* Run generic safe diagnostic test
*/
fun runDiagnosticTest() {
viewModelScope.launch {
_commandHistory.value = emptyList()
for (cmd in ObdCommands.SAFE_DIAGNOSTIC_COMMANDS) {
sendCommand(cmd.command)
kotlinx.coroutines.delay(500) // Wait between commands
}
}
}
/**
* Clear command history
*/
fun clearHistory() {
_commandHistory.value = emptyList()
}
/**
* Clear error message
*/
fun clearError() {
_errorMessage.value = null
}
private fun addCommandToHistory(command: String, rawResponse: String, parsedResponse: String) {
val history = _commandHistory.value.toMutableList()
history.add(
0, // Add to beginning
CommandResult(
command = command,
rawResponse = rawResponse,
parsedResponse = parsedResponse,
timestamp = System.currentTimeMillis()
)
)
// Keep only last 50 commands
_commandHistory.value = history.take(50)
}
override fun onCleared() {
super.onCleared()
bluetoothService.disconnect()
}
}
/**
* Data class representing a command result
*/
data class CommandResult(
val command: String,
val rawResponse: String,
val parsedResponse: String,
val timestamp: Long
)

Ver fichero

@@ -0,0 +1,109 @@
package com.manalejandro.odb2bluetooth.ui.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.odb2bluetooth.data.database.ObdDatabase
import com.manalejandro.odb2bluetooth.data.database.dto.SignalDto
import com.manalejandro.odb2bluetooth.data.database.dto.VehicleDto
import com.manalejandro.odb2bluetooth.data.repository.ObdRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* ViewModel for vehicle selection and signal browsing
*/
class VehicleViewModel(application: Application) : AndroidViewModel(application) {
private val database = ObdDatabase.getDatabase(application)
private val repository = ObdRepository(
database.vehicleDao(),
database.obdSignalDao(),
database.dtcCodeDao()
)
private val _vehicles = MutableStateFlow<List<VehicleDto>>(emptyList())
val vehicles: StateFlow<List<VehicleDto>> = _vehicles
private val _selectedVehicle = MutableStateFlow<VehicleDto?>(null)
val selectedVehicle: StateFlow<VehicleDto?> = _selectedVehicle
private val _signals = MutableStateFlow<List<SignalDto>>(emptyList())
val signals: StateFlow<List<SignalDto>> = _signals
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
init {
loadVehicles()
}
/**
* Load all vehicles from database
*/
private fun loadVehicles() {
viewModelScope.launch {
_isLoading.value = true
repository.getAllVehicles().collectLatest { vehicleList ->
_vehicles.value = vehicleList
_isLoading.value = false
}
}
}
/**
* Search vehicles by query
*/
fun searchVehicles(query: String) {
_searchQuery.value = query
viewModelScope.launch {
_isLoading.value = true
if (query.isBlank()) {
repository.getAllVehicles().collectLatest { vehicleList ->
_vehicles.value = vehicleList
_isLoading.value = false
}
} else {
repository.searchVehicles(query).collectLatest { vehicleList ->
_vehicles.value = vehicleList
_isLoading.value = false
}
}
}
}
/**
* Select a vehicle and load its signals
*/
fun selectVehicle(vehicle: VehicleDto) {
_selectedVehicle.value = vehicle
loadSignalsForVehicle(vehicle.modelId)
}
/**
* Clear vehicle selection
*/
fun clearSelection() {
_selectedVehicle.value = null
_signals.value = emptyList()
}
/**
* Load signals for a specific vehicle
*/
private fun loadSignalsForVehicle(modelId: Int) {
viewModelScope.launch {
_isLoading.value = true
repository.getSignalsByModel(modelId).collectLatest { signalList ->
_signals.value = signalList
_isLoading.value = false
}
}
}
}

Ver fichero

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

Ver fichero

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

Ver fichero

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Ver fichero

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.4 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.8 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 982 B

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.7 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.9 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.8 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.8 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 5.8 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.8 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 7.6 KiB

Ver fichero

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

Ver fichero

@@ -0,0 +1,4 @@
<resources>
<string name="app_name">OBD2 Bluetooth</string>
</resources>

Ver fichero

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.ODB2Bluetooth" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

Ver fichero

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

Ver fichero

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

Ver fichero

@@ -0,0 +1,17 @@
package com.manalejandro.odb2bluetooth
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}