commit f79ebfdecc67c0272a8697ab22bdac0dd195107b Author: Sathwik Date: Wed Aug 6 17:41:36 2025 +0530 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..60020d4 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +PCR app \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..32522c1 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..0ad17cb --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e1bb36f --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,92 @@ +plugins { + kotlin("kapt") + id("com.google.dagger.hilt.android") + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) +} + +android { + namespace = "in.sminnovations.pcr" + compileSdk = 34 + + defaultConfig { + applicationId = "in.sminnovations.pcr" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3.android) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + implementation ("androidx.camera:camera-camera2:1.3.4") + implementation ( "androidx.camera:camera-lifecycle:1.3.4") + implementation ("androidx.camera:camera-view:1.3.4") + implementation ("com.google.mlkit:barcode-scanning:17.3.0") + implementation ("com.google.accompanist:accompanist-permissions:0.31.2-alpha") + + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") + + implementation ("androidx.navigation:navigation-compose:2.7.2") + + implementation("androidx.datastore:datastore-preferences:1.1.1") + + implementation("com.google.dagger:hilt-android:2.51.1") + kapt("com.google.dagger:hilt-android-compiler:2.51.1") + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + + val nav_version = "2.8.2" + implementation("androidx.navigation:navigation-compose:$nav_version") + + +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/in/sminnovations/pcr/ExampleInstrumentedTest.kt b/app/src/androidTest/java/in/sminnovations/pcr/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..17c3fe4 --- /dev/null +++ b/app/src/androidTest/java/in/sminnovations/pcr/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package `in`.sminnovations.pcr + +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("in.sminnovations.pcr", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b6f9559 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/MainActivity.kt b/app/src/main/java/in/sminnovations/pcr/MainActivity.kt new file mode 100644 index 0000000..527d359 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/MainActivity.kt @@ -0,0 +1,24 @@ +package `in`.sminnovations.pcr + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import dagger.hilt.android.AndroidEntryPoint +import `in`.sminnovations.pcr.ui.navigation.AppNavigation + +import `in`.sminnovations.pcr.ui.theme.PCRAppTheme + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + PCRAppTheme { + AppNavigation() + } + } + } +} + diff --git a/app/src/main/java/in/sminnovations/pcr/PCRApplication.kt b/app/src/main/java/in/sminnovations/pcr/PCRApplication.kt new file mode 100644 index 0000000..d3bd028 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/PCRApplication.kt @@ -0,0 +1,9 @@ +package `in`.sminnovations.pcr + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class PCRApplication : Application() { + +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ble/BleManager.kt b/app/src/main/java/in/sminnovations/pcr/ble/BleManager.kt new file mode 100644 index 0000000..65f9e70 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ble/BleManager.kt @@ -0,0 +1,403 @@ +package `in`.sminnovations.pcr.ble + +import `in`.sminnovations.pcr.data.model.BleDevice +import `in`.sminnovations.pcr.data.model.ConnectionState + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.* +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import `in`.sminnovations.pcr.data.utils.HexUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "BleManager" +private const val SCAN_PERIOD: Long = 10000 // 10 seconds scan +private const val CONNECTION_TIMEOUT: Long = 20000 // 20 seconds + +@SuppressLint("MissingPermission") // Permissions checked at call site +@Singleton +class BleManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter + private val handler = Handler(Looper.getMainLooper()) + private var isScanning = false + + private val _bleState = MutableSharedFlow( + replay = 0, // No replay for events + extraBufferCapacity = 10, // Buffer events if collector is slow + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val bleState: SharedFlow = _bleState.asSharedFlow() + + private val _scanResults = MutableStateFlow>(emptyList()) + val scanResults: StateFlow> = _scanResults.asStateFlow() + + // Store connection state per device address + private val _connectionStates = MutableStateFlow>(emptyMap()) + val connectionStates: StateFlow> = _connectionStates.asStateFlow() + + private val connectedGattMap = mutableMapOf() + private val gattCallbackMap = mutableMapOf() + + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // --- Define your Service and Characteristic UUIDs --- + // Replace these with the actual UUIDs from your device's specification + companion object { + val DEVICE_SERVICE_UUID: UUID = UUID.fromString("00001500-1212-efde-1523-785feabcd123") + val RT_TEMP_UUID: UUID = UUID.fromString("00001503-1212-efde-1523-785feabcd123") + val RT_TIME_MIN_UUID: UUID = UUID.fromString("00001504-1212-efde-1523-785feabcd123") + val RT_TIME_SEC_UUID: UUID = UUID.fromString("00001505-1212-efde-1523-785feabcd123") + val ID_TEMP_UUID: UUID = UUID.fromString("00001506-1212-efde-1523-785feabcd123") + val ID_TIME_MIN_UUID: UUID = UUID.fromString("00001507-1212-efde-1523-785feabcd123") + val ID_TIME_SEC_UUID: UUID = UUID.fromString("00001508-1212-efde-1523-785feabcd123") + val D_TEMP_UUID: UUID = UUID.fromString("00001509-1212-efde-1523-785feabcd123") + val D_TIME_SEC_UUID: UUID = UUID.fromString("00001510-1212-efde-1523-785feabcd123") + val A_TEMP_UUID: UUID = UUID.fromString("00001511-1212-efde-1523-785feabcd123") + val A_TIME_SEC_UUID: UUID = UUID.fromString("00001512-1212-efde-1523-785feabcd123") + val E_TEMP_UUID: UUID = UUID.fromString("00001513-1212-efde-1523-785feabcd123") + val E_TIME_SEC_UUID: UUID = UUID.fromString("00001514-1212-efde-1523-785feabcd123") + val FE_TEMP_UUID: UUID = UUID.fromString("00001515-1212-efde-1523-785feabcd123") + val FE_TIME_MIN_UUID: UUID = UUID.fromString("00001516-1212-efde-1523-785feabcd123") + val FE_TIME_SEC_UUID: UUID = UUID.fromString("00001517-1212-efde-1523-785feabcd123") + val CYCLE_UUID: UUID = UUID.fromString("00001518-1212-efde-1523-785feabcd123") + val SWITCH_UUID: UUID = UUID.fromString("00001519-1212-efde-1523-785feabcd123") + val DEVICE_NOTIFY_UUID_TEMP: UUID = UUID.fromString("00001501-1212-efde-1523-785feabcd123") + val DEVICE_NOTIFY_UUID_STEP_NUMBER: UUID = UUID.fromString("00001502-1212-efde-1523-785feabcd123") + + // Standard BLE UUID for Client Characteristic Configuration Descriptor + val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + } + // --- End UUIDs --- + + fun getGatt(deviceAddress: String): BluetoothGatt? { + return connectedGattMap[deviceAddress] + } + + val requiredPermissions: List + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.ACCESS_FINE_LOCATION // Still needed for some scenarios + ) + } else { + listOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_ADMIN, + Manifest.permission.ACCESS_FINE_LOCATION + ) + } + + fun startBleScan() { + if (isScanning) { + Log.w(TAG, "Scan already in progress") + return + } + if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) { + Log.e(TAG, "Bluetooth is not enabled or not available.") + coroutineScope.launch { _bleState.emit(BleState.Error("Bluetooth disabled")) } + return + } + + Log.i(TAG, "Starting BLE Scan...") + isScanning = true + _scanResults.value = emptyList() // Clear previous results + + val scanner = bluetoothAdapter.bluetoothLeScanner ?: run { + Log.e(TAG, "Failed to get BLE scanner.") + isScanning = false + coroutineScope.launch { _bleState.emit(BleState.Error("BLE Scanner unavailable")) } + return + } + + // Stop scan after SCAN_PERIOD + handler.postDelayed({ stopBleScan() }, SCAN_PERIOD) + + // Start scanning + // Add filters if needed (e.g., ScanFilter.Builder().setServiceUuid(ParcelUuid(DEVICE_SERVICE_UUID)).build()) + val scanSettings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // Or BALANCED for better battery + .build() + scanner.startScan(null, scanSettings, leScanCallback) + } + + fun stopBleScan() { + if (!isScanning) return + Log.i(TAG, "Stopping BLE Scan.") + isScanning = false + bluetoothAdapter?.bluetoothLeScanner?.stopScan(leScanCallback) + handler.removeCallbacksAndMessages(null) // Remove stop callback + coroutineScope.launch { _bleState.emit(BleState.ScanFinished) } + } + + private val leScanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult?) { + super.onScanResult(callbackType, result) + result?.device?.let { device -> + val existingDevice = _scanResults.value.find { it.address == device.address } + val bleDevice = BleDevice( + name = device.name ?: "Unknown", + address = device.address, + rssi = result.rssi, + bluetoothDevice = device + ) + if (existingDevice == null) { + if (device.name != null) { // Only add named devices initially if desired + _scanResults.value += bleDevice + coroutineScope.launch { _bleState.emit(BleState.ScanResult(bleDevice)) } + } + } else { + // Update RSSI if device already exists + _scanResults.value = _scanResults.value.map { + if (it.address == bleDevice.address) bleDevice else it + } + } + } + } + + override fun onBatchScanResults(results: MutableList?) { + super.onBatchScanResults(results) + Log.d(TAG, "Batch scan results received: ${results?.size}") + } + + override fun onScanFailed(errorCode: Int) { + super.onScanFailed(errorCode) + Log.e(TAG, "BLE Scan Failed with error code: $errorCode") + isScanning = false + coroutineScope.launch { _bleState.emit(BleState.Error("Scan failed: $errorCode")) } + } + } + + fun connectToDevice(deviceAddress: String) { + if (bluetoothAdapter == null) { + Log.w(TAG, "BluetoothAdapter not initialized.") + updateConnectionState(deviceAddress, ConnectionState.FAILED) + return + } + val device = bluetoothAdapter.getRemoteDevice(deviceAddress) + if (device == null) { + Log.w(TAG, "Device not found. Unable to connect.") + updateConnectionState(deviceAddress, ConnectionState.FAILED) + return + } + + // Close previous connection if any + connectedGattMap[deviceAddress]?.close() + gattCallbackMap.remove(deviceAddress) // Clean up old callback + + Log.i(TAG, "Attempting to connect to device: $deviceAddress") + updateConnectionState(deviceAddress, ConnectionState.CONNECTING) + + val gattCallback = createGattCallback(deviceAddress) + gattCallbackMap[deviceAddress] = gattCallback + + val gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + connectedGattMap[deviceAddress] = gatt + + // Start connection timeout + coroutineScope.launch(Dispatchers.Main) { // Use Main dispatcher for handler + delay(CONNECTION_TIMEOUT) + if (_connectionStates.value[deviceAddress] == ConnectionState.CONNECTING) { + Log.w(TAG, "Connection timed out for $deviceAddress") + disconnectFromDevice(deviceAddress) // This will trigger state update to FAILED + coroutineScope.launch { _bleState.emit(BleState.Error("Connection Timeout: $deviceAddress")) } + + } + } + } + + fun disconnectFromDevice(deviceAddress: String) { + Log.i(TAG, "Disconnecting from device: $deviceAddress") + connectedGattMap[deviceAddress]?.disconnect() + // Don't close immediately, wait for onConnectionStateChange(STATE_DISCONNECTED) + // connectedGattMap[deviceAddress]?.close() + // connectedGattMap.remove(deviceAddress) + // gattCallbackMap.remove(deviceAddress) + // updateConnectionState(deviceAddress, ConnectionState.DISCONNECTED) + } + + fun closeGatt(deviceAddress: String) { + connectedGattMap[deviceAddress]?.close() + connectedGattMap.remove(deviceAddress) + gattCallbackMap.remove(deviceAddress) + // Ensure state reflects disconnected if not already set + if (_connectionStates.value[deviceAddress] != ConnectionState.DISCONNECTED) { + updateConnectionState(deviceAddress, ConnectionState.DISCONNECTED) + } + } + + // --- Characteristic Interaction --- + + suspend fun writeCharacteristic(deviceAddress: String, serviceUUID: UUID, charUUID: UUID, data: ByteArray): Boolean { + val gatt = connectedGattMap[deviceAddress] ?: return false + val service = gatt.getService(serviceUUID) ?: return false + val characteristic = service.getCharacteristic(charUUID) ?: return false + + characteristic.value = data + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT // Or WRITE_TYPE_NO_RESPONSE + val success = gatt.writeCharacteristic(characteristic) + Log.d(TAG, "Writing to $charUUID on $deviceAddress - Success: $success") + _bleState.emit(BleState.WriteResult(deviceAddress, charUUID.toString(), success)) + return success + // Note: Actual success/failure confirmation comes in onCharacteristicWrite callback + } + + + suspend fun enableNotifications(deviceAddress: String, serviceUUID: UUID, charUUID: UUID): Boolean { + val gatt = connectedGattMap[deviceAddress] ?: return false + val service = gatt.getService(serviceUUID) ?: return false + val characteristic = service.getCharacteristic(charUUID) ?: return false + + if (!gatt.setCharacteristicNotification(characteristic, true)) { + Log.w(TAG, "Failed to enable notification for $charUUID") + return false + } + + val descriptor = characteristic.getDescriptor(CCCD_UUID) ?: run { + Log.w(TAG, "CCCD Descriptor not found for $charUUID") + // Some devices might not require explicit descriptor write, but it's standard + return true // Assume success if descriptor isn't found but setCharacteristicNotification worked + } + + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + val success = gatt.writeDescriptor(descriptor) + Log.d(TAG, "Writing CCCD for $charUUID on $deviceAddress - Success: $success") + // Actual confirmation comes in onDescriptorWrite callback + return success + } + + + // --- Private Helpers --- + + private fun updateConnectionState(address: String, state: ConnectionState) { + _connectionStates.value = _connectionStates.value.toMutableMap().apply { + this[address] = state + } + coroutineScope.launch { _bleState.emit(BleState.ConnectionUpdate(address, state)) } + } + + private fun createGattCallback(deviceAddress: String): BluetoothGattCallback { + return object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { + super.onConnectionStateChange(gatt, status, newState) + val address = gatt?.device?.address ?: deviceAddress // Fallback + Log.i(TAG, "onConnectionStateChange for $address - Status: $status, NewState: $newState") + + if (status == BluetoothGatt.GATT_SUCCESS) { + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + Log.i(TAG, "Connected to $address. Discovering services...") + updateConnectionState(address, ConnectionState.CONNECTED) + gatt?.discoverServices() + } + BluetoothProfile.STATE_DISCONNECTED -> { + Log.i(TAG, "Disconnected from $address.") + closeGatt(address) // Clean up resources now + // State already updated via closeGatt or disconnect request + } + else -> { + Log.w(TAG, "Unhandled connection state: $newState for $address") + } + } + } else { + Log.e(TAG, "GATT Error onConnectionStateChange for $address. Status: $status, NewState: $newState") + closeGatt(address) // Clean up on failure + updateConnectionState(address, ConnectionState.FAILED) + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { + super.onServicesDiscovered(gatt, status) + val address = gatt?.device?.address ?: deviceAddress + if (status == BluetoothGatt.GATT_SUCCESS) { + Log.i(TAG, "Services discovered successfully for $address.") + // Example: Automatically enable notifications for the step characteristic + coroutineScope.launch { + if (enableNotifications(address, DEVICE_SERVICE_UUID, DEVICE_NOTIFY_UUID_STEP_NUMBER)) { + Log.i(TAG, "Notification enabled request sent for STEP on $address") + updateConnectionState(address, ConnectionState.READY) // Consider ready after enabling essential notifications + } else { + Log.e(TAG, "Failed to request notification enable for STEP on $address") + updateConnectionState(address, ConnectionState.SERVICE_DISCOVERY_FAILED) // Indicate partial failure + } + // You might want to enable other notifications here too + } + } else { + Log.e(TAG, "Service discovery failed for $address with status: $status") + closeGatt(address) + updateConnectionState(address, ConnectionState.SERVICE_DISCOVERY_FAILED) + } + } + + // Inside BleManager.kt createGattCallback method + + override fun onCharacteristicWrite(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) { + super.onCharacteristicWrite(gatt, characteristic, status) + val address = gatt?.device?.address ?: deviceAddress // Use outer scope address as fallback + val uuid = characteristic?.uuid?.toString() ?: "UNKNOWN_CHAR" + val valueWritten = HexUtil.bytesToHexString(characteristic?.value ?: byteArrayOf()) + + if (status == BluetoothGatt.GATT_SUCCESS) { + // Log SUCCESSFUL asynchronous write confirmation + Log.i(TAG, "<<< Write Confirmed (SUCCESS): Char $uuid on $address, Value=0x$valueWritten, Status=$status") + // Emit success state (already done in writeCharacteristic, maybe redundant unless you want async confirmation) + // coroutineScope.launch { _bleState.emit(BleState.WriteResult(address, uuid, true)) } + } else { + // Log FAILED asynchronous write confirmation + Log.e(TAG, "<<< Write Confirmed (FAILED): Char $uuid on $address, Value=0x$valueWritten, Status=$status (GATT Error Code)") + // Emit failure state + coroutineScope.launch { _bleState.emit(BleState.WriteResult(address, uuid, false)) } + // Optionally: Trigger a disconnect or specific error state based on failure here? + } + } + override fun onCharacteristicChanged(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) { + super.onCharacteristicChanged(gatt, characteristic) + val address = gatt?.device?.address ?: deviceAddress + val uuid = characteristic?.uuid?.toString() ?: "UNKNOWN_CHAR" + val data = characteristic?.value ?: byteArrayOf() + Log.d(TAG, "Characteristic changed $uuid on $address: ${HexUtil.bytesToHexString(data)}") + coroutineScope.launch { _bleState.emit(BleState.DataReceived(address, uuid, data)) } + } + + override fun onDescriptorWrite(gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, status: Int) { + super.onDescriptorWrite(gatt, descriptor, status) + val address = gatt?.device?.address ?: deviceAddress + val charUuid = descriptor?.characteristic?.uuid?.toString() ?: "UNKNOWN_CHAR" + val descUuid = descriptor?.uuid?.toString() ?: "UNKNOWN_DESC" + + if (status == BluetoothGatt.GATT_SUCCESS) { + if (descUuid.equals(CCCD_UUID.toString(), ignoreCase = true)) { + Log.i(TAG, "Notification enabled successfully for $charUuid on $address") + // If this was the last setup step, you might transition to READY state here + } else { + Log.d(TAG, "Descriptor write successful for $descUuid on $charUuid/$address") + } + } else { + Log.e(TAG, "Descriptor write failed for $descUuid on $charUuid/$address - Status: $status") + } + } + + // Implement other callbacks like onCharacteristicRead, onDescriptorRead etc. if needed + } + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ble/BleState.kt b/app/src/main/java/in/sminnovations/pcr/ble/BleState.kt new file mode 100644 index 0000000..af146ea --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ble/BleState.kt @@ -0,0 +1,31 @@ +package `in`.sminnovations.pcr.ble + +import `in`.sminnovations.pcr.data.model.BleDevice +import `in`.sminnovations.pcr.data.model.ConnectionState + + +sealed class BleState { + data class ScanResult(val device: BleDevice) : BleState() + object ScanFinished : BleState() + data class ConnectionUpdate(val deviceAddress: String, val state: ConnectionState) : BleState() + data class DataReceived(val deviceAddress: String, val characteristicUUID: String, val data: ByteArray) : BleState() { + // Optional: Override equals/hashCode if needed for StateFlow distinctness + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DataReceived + if (deviceAddress != other.deviceAddress) return false + if (characteristicUUID != other.characteristicUUID) return false + if (!data.contentEquals(other.data)) return false + return true + } + override fun hashCode(): Int { + var result = deviceAddress.hashCode() + result = 31 * result + characteristicUUID.hashCode() + result = 31 * result + data.contentHashCode() + return result + } + } + data class WriteResult(val deviceAddress: String, val characteristicUUID: String, val success: Boolean) : BleState() + data class Error(val message: String, val exception: Throwable? = null) : BleState() +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ble/ProtocolRunner.kt b/app/src/main/java/in/sminnovations/pcr/ble/ProtocolRunner.kt new file mode 100644 index 0000000..de7a419 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ble/ProtocolRunner.kt @@ -0,0 +1,432 @@ +package `in`.sminnovations.pcr.ble + + +import android.bluetooth.BluetoothGattCharacteristic +import android.util.Log +import `in`.sminnovations.pcr.data.model.SimpleProtocol +import `in`.sminnovations.pcr.data.utils.HexUtil +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.UUID +import javax.inject.Inject + +private const val TAG = "ProtocolRunner" +private const val BLE_WRITE_DELAY = 500L // Delay between writes (adjust as needed) + +class ProtocolRunner @Inject constructor(private val bleManager: BleManager) { + + private val _isRunning = MutableStateFlow(false) + val isRunning: StateFlow = _isRunning.asStateFlow() + + private val _currentStepIndex = MutableStateFlow(-1) // -1 means not started + val currentStepIndex: StateFlow = _currentStepIndex.asStateFlow() + + private val _remainingTimeSeconds = MutableStateFlow(0) + val remainingTimeSeconds: StateFlow = _remainingTimeSeconds.asStateFlow() + + private val _totalTimeSeconds = MutableStateFlow(0) + val totalTimeSeconds: StateFlow = _totalTimeSeconds.asStateFlow() + + + private var runnerJob: Job? = null + private var timerJob: Job? = null + private var currentDeviceAddress: String? = null + + // Simplified mapping of steps to UUIDs (Adapt based on your actual needs) +// Inside ProtocolRunner.kt + + // Return: Triple(Temp UUID, Minutes UUID, Seconds UUID) - Null if not applicable + private fun getCharacteristicUuidsForStep(stepName: String): Triple { + return when { + stepName.contains("Initial Denaturation", ignoreCase = true) -> + Triple(BleManager.ID_TEMP_UUID, BleManager.ID_TIME_MIN_UUID, BleManager.ID_TIME_SEC_UUID) + stepName.contains("Denaturation", ignoreCase = true) -> + Triple(BleManager.D_TEMP_UUID, null, BleManager.D_TIME_SEC_UUID) // Only seconds for this step + stepName.contains("Annealing", ignoreCase = true) -> + Triple(BleManager.A_TEMP_UUID, null, BleManager.A_TIME_SEC_UUID) // Only seconds for this step + stepName.contains("Extension", ignoreCase = true) -> + Triple(BleManager.E_TEMP_UUID, null, BleManager.E_TIME_SEC_UUID) // Only seconds for this step + stepName.contains("Final Extension", ignoreCase = true) -> + Triple(BleManager.FE_TEMP_UUID, BleManager.FE_TIME_MIN_UUID, BleManager.FE_TIME_SEC_UUID) + stepName.contains("Hold", ignoreCase = true) -> + Triple(BleManager.FE_TEMP_UUID, null, null) // Only Temp for hold? Check device spec + // Add RT case if needed: + // stepName.contains("Reverse Transcription", ignoreCase = true) -> + // Triple(BleManager.RT_TEMP_UUID, BleManager.RT_TIME_MIN_UUID, BleManager.RT_TIME_SEC_UUID) + else -> Triple(null, null, null) + } + } + fun startProtocol(deviceAddress: String, scope: CoroutineScope) { + if (_isRunning.value) { + Log.w(TAG, "Protocol already running.") + return + } + // Stop any local running jobs first (doesn't send BLE command yet) + runnerJob?.cancel() + timerJob?.cancel() + // Reset internal state immediately *before* starting the new job + stopProtocolInternal() // Clear state like timers, step index + + currentDeviceAddress = deviceAddress + // Don't set _isRunning to true yet + + runnerJob = scope.launch(Dispatchers.IO) { + var protocolSuccessfullyStarted = false // Flag to track if we got past setup + try { + Log.i(TAG, "Starting protocol execution coroutine for $deviceAddress") + + // --- ADDED: Attempt to send STOP command first --- + Log.d(TAG, "[Step 0] Attempting to send OFF command first as reset...") + // We don't necessarily need to wait for success here, just send it. + // The goal is to interrupt any existing run on the device. + val stopSuccess = sendSwitchCommand(deviceAddress, false) + Log.d(TAG, "[Step 0] sendSwitchCommand(OFF) immediate return: $stopSuccess") + // Add a small delay after sending stop, just in case + delay(500L) // Give device a moment to potentially process stop + // --- END ADDED STOP --- + + // Now proceed with the original setup... + // --- 1. Send Number of Cycles --- + Log.d(TAG, "[Step 1] Attempting to send cycle count: ${SimpleProtocol.CYCLES}") + val cycleSuccess = sendCycleCount(deviceAddress, SimpleProtocol.CYCLES) + Log.d(TAG, "[Step 1] sendCycleCount success: $cycleSuccess") + if (!cycleSuccess) { + Log.e(TAG, "[Step 1] Failed to send cycle count. Aborting protocol.") + return@launch + } + delay(BLE_WRITE_DELAY) // Use the standard delay + + // --- 2. Calculate Total Time --- + withContext(Dispatchers.Default) { + _totalTimeSeconds.value = calculateTotalTime() + Log.d(TAG, "[Step 2] Calculated total approximate time: ${_totalTimeSeconds.value} seconds") + } + + // --- 3. Turn ON the device/reaction --- + Log.d(TAG, "[Step 3] Attempting to send ON command...") + val switchOnSuccess = sendSwitchCommand(deviceAddress, true) + Log.d(TAG, "[Step 3] sendSwitchCommand(ON) success: $switchOnSuccess") + if (!switchOnSuccess) { + Log.e(TAG, "[Step 3] Failed to send ON command. Aborting protocol.") + return@launch + } + // Use the longer delay established before + val postSwitchDelay = 1000L + Log.d(TAG, "[Step 3 -> 4] Delaying for $postSwitchDelay ms after sending ON command.") + delay(postSwitchDelay) + + // ---- If we reach here, setup was successful, now set running state ---- + withContext(Dispatchers.Main) { // Update StateFlow on Main thread + _isRunning.value = true + _currentStepIndex.value = 0 // Set initial step index + } + protocolSuccessfullyStarted = true // Mark that the protocol actually started running + // ---------------------------------------------------------------------- + + // --- 4. Execute Steps Sequence --- + Log.d(TAG, "[Step 4] Starting step execution loop...") + var actualStepIndex = 0 // Start from the first step + val intraStepDelay = 500L // Delay between parameters of the SAME step + val interStepDelay = BLE_WRITE_DELAY // Delay AFTER a step's params, before timer/next step (e.g., 250ms or 500ms) + + // --- 4. Execute Steps Sequence --- + Log.d(TAG, "[Step 4] Starting step execution loop...") + while (actualStepIndex < SimpleProtocol.steps.size && isActive) { + // ... (Update _currentStepIndex, get step, get stepTotalSeconds) ... + withContext(Dispatchers.Main) { _currentStepIndex.value = actualStepIndex } + val step = SimpleProtocol.steps[actualStepIndex] + val stepTotalSeconds = SimpleProtocol.getStepTotalSeconds(step) + val stepNumberForLog = actualStepIndex + 1 + + Log.i(TAG, "[Step 4.$stepNumberForLog] === Executing: ${step.name} (${step.durationMinutes}m ${step.durationSeconds}s) ===") + + // --- Send step parameters (Temp -> Min -> Sec Order) --- + val (tempUuid, minUuid, secUuid) = getCharacteristicUuidsForStep(step.name) + var writeSuccess = true + + // Send Temp (if applicable) + if (tempUuid != null) { + Log.d(TAG, "[Step 4.$stepNumberForLog] Sending Temp (${step.temperature}) to $tempUuid") + writeSuccess = sendCommand(deviceAddress, tempUuid, step.temperature) + Log.d(TAG, "[Step 4.$stepNumberForLog] Temp write success: $writeSuccess") + if (!writeSuccess) break + delay(intraStepDelay) // <<< Delay AFTER Temp + } + + // Send Minutes (if applicable) + if (minUuid != null && writeSuccess) { + Log.d(TAG, "[Step 4.$stepNumberForLog] Sending Minutes (${step.durationMinutes}) to $minUuid") + writeSuccess = sendCommand(deviceAddress, minUuid, step.durationMinutes) + Log.d(TAG, "[Step 4.$stepNumberForLog] Minutes write success: $writeSuccess") + if (!writeSuccess) break + delay(intraStepDelay) // <<< Delay AFTER Minutes + } + + // Send Seconds (if applicable) + if (secUuid != null && writeSuccess) { + Log.d(TAG, "[Step 4.$stepNumberForLog] Sending Seconds (${step.durationSeconds}) to $secUuid") + writeSuccess = sendCommand(deviceAddress, secUuid, step.durationSeconds) + Log.d(TAG, "[Step 4.$stepNumberForLog] Seconds write success: $writeSuccess") + if (!writeSuccess) break + // No intraStepDelay needed after the last parameter for the step + } + + // --- End sending parameters --- + + // Only add the longer delay AFTER all parameters for the step are sent successfully + if (writeSuccess) { + Log.d(TAG, "[Step 4.$stepNumberForLog] All parameters sent successfully. Delaying before timer/next step.") + delay(interStepDelay) // Use the longer delay between steps + } else { + Log.e(TAG, "[Step 4.$stepNumberForLog] Failed during command sending for step ${step.name}. Aborting loop.") + break + } + + // Start Timer using the total seconds for the step + if (stepTotalSeconds > 0 && isActive) { + Log.d(TAG, "[Step 4.$stepNumberForLog] Starting timer for ${stepTotalSeconds}s") + startStepTimer(stepTotalSeconds, scope) // Use the original scope for timer + try { + timerJob?.join() // Wait for the timer coroutine to complete or be cancelled + Log.d(TAG, "[Step 4.$stepNumberForLog] Timer finished or protocol stopped/cancelled during join.") + } catch(e: CancellationException){ + Log.d(TAG, "[Step 4.$stepNumberForLog] Timer job cancelled while waiting.") + // Rethrow or handle as needed, loop check 'isActive' will catch it too + break // Exit loop if timer was cancelled externally + } + } else if (isActive) { // Only log if not cancelled + Log.i(TAG, "[Step 4.$stepNumberForLog] Step ${step.name} has 0 duration or is 'Hold'.") + if (step.name.contains("Hold", ignoreCase = true)) { + Log.i(TAG, "[Step 4.$stepNumberForLog] Entering Hold state.") + // Since we can't easily wait indefinitely, let's just log and finish the protocol run + actualStepIndex = SimpleProtocol.steps.size // Force loop exit after Hold + continue // Skip normal increment and cycle logic check + } + } + + // Double-check cancellation status after timer/hold logic + if (!isActive) { + Log.d(TAG, "[Step 4.$stepNumberForLog] Protocol cancelled after step execution/timer.") + break + } + + // --- Cycle Logic (Simplified - assumes firmware handles looping) --- + var nextActualStepIndex = actualStepIndex + 1 + if (actualStepIndex in SimpleProtocol.cycleStartIndex..SimpleProtocol.cycleEndIndex) { + if (actualStepIndex == SimpleProtocol.cycleEndIndex) { + Log.d(TAG,"[Step 4.$stepNumberForLog] Reached end of defined cycle steps. Moving to step after cycle block.") + nextActualStepIndex = SimpleProtocol.cycleEndIndex + 1 + } else { + nextActualStepIndex = actualStepIndex + 1 + } + } + Log.d(TAG, "[Step 4.$stepNumberForLog] Moving from index $actualStepIndex to $nextActualStepIndex") + actualStepIndex = nextActualStepIndex + + } // End of while loop + + // --- Protocol Finish --- + if (isActive) { + if (actualStepIndex >= SimpleProtocol.steps.size) { + Log.i(TAG, "Protocol execution loop completed successfully.") + _currentStepIndex.value = SimpleProtocol.steps.size + if (!SimpleProtocol.steps.last().name.contains("Hold", ignoreCase = true)) { + Log.d(TAG, "Sending final OFF command.") + sendSwitchCommand(deviceAddress, false) + } else { + Log.d(TAG, "Last step is 'Hold', not sending OFF command.") + } + } else { + Log.w(TAG, "Protocol execution loop exited prematurely due to write failure.") + // Attempt OFF only if we successfully started + if(protocolSuccessfullyStarted) sendSwitchCommand(deviceAddress, false) + } + } else { + Log.i(TAG, "Protocol execution loop cancelled.") + // OFF command potentially sent in CancellationException handler + } + + } catch (e: CancellationException) { + Log.i(TAG, "Protocol runner job was cancelled.") + if (protocolSuccessfullyStarted && currentDeviceAddress != null) { + Log.d(TAG, "Sending OFF command due to cancellation.") + // Use NonCancellable context if you MUST send this command even during cancellation + withContext(NonCancellable) { + sendSwitchCommand(currentDeviceAddress, false) + } + } + // Don't re-throw cancellation from within the coroutine itself, let the caller handle it. + } catch (e: Exception) { + Log.e(TAG, "!!! Unhandled Error during protocol execution !!!", e) + if (protocolSuccessfullyStarted && currentDeviceAddress != null) { + Log.d(TAG, "Sending OFF command due to error.") + withContext(NonCancellable) { // Try to ensure OFF command is sent + sendSwitchCommand(currentDeviceAddress, false) + } + } + } finally { + Log.d(TAG, "ProtocolRunner Coroutine Finally block executing. Cleaning up state.") + withContext(NonCancellable) { + stopProtocolInternal() // Reset ViewModel state (_isRunning = false, etc.) + } + } + } + } + fun stopProtocol() { + Log.i(TAG, "Stopping protocol requested.") + runnerJob?.cancel() // Cancel the main runner loop (will trigger finally) + timerJob?.cancel() // Cancel any active step timer explicitly + // The OFF command is now handled within the runnerJob's cancellation/error/finally path + // We might need to manually call stopProtocolInternal here if runnerJob is null or already complete + if(runnerJob == null || runnerJob?.isCompleted == true) { + stopProtocolInternal() + } + // Clear address only when explicitly stopped externally or new run starts + currentDeviceAddress = null + } + + private fun stopProtocolInternal() { + if (_isRunning.value || _currentStepIndex.value != -1) { // Check if cleanup is needed + Log.i(TAG, "Cleaning up protocol state (isRunning, stepIndex, timers).") + _isRunning.value = false + _currentStepIndex.value = -1 + _remainingTimeSeconds.value = 0 + _totalTimeSeconds.value = 0 + timerJob?.cancel() // Ensure timer is cancelled here too + // DO NOT clear currentDeviceAddress here, it might be needed by cancellation/error handlers + // It will be cleared when stopProtocol() is called externally or a new run starts + } + } + + private suspend fun sendCycleCount(deviceAddress: String, cycles: Int): Boolean { + Log.d(TAG, "Sending Cycle Count: $cycles") + return sendCommand(deviceAddress, BleManager.CYCLE_UUID, cycles) + } + +// Inside ProtocolRunner.kt + + private suspend fun sendSwitchCommand(deviceAddress: String?, on: Boolean): Boolean { + deviceAddress ?: return false // Don't send if address is null + val value = if (on) 1 else 0 + val commandName = if (on) "ON" else "OFF" + Log.d(TAG, "Sending Switch Command: $commandName (Value: $value)") + + // --- Add Verification --- + val gatt = bleManager.getGatt(deviceAddress) // Add a helper in BleManager if needed + if (gatt == null) { + Log.e(TAG, "sendSwitchCommand: GATT is null for $deviceAddress. Cannot write.") + return false + } + val service = gatt.getService(BleManager.DEVICE_SERVICE_UUID) + if (service == null) { + Log.e(TAG, "sendSwitchCommand: Service ${BleManager.DEVICE_SERVICE_UUID} not found on $deviceAddress.") + return false + } + val characteristic = service.getCharacteristic(BleManager.SWITCH_UUID) + if (characteristic == null) { + Log.e(TAG, "sendSwitchCommand: Characteristic ${BleManager.SWITCH_UUID} not found in service ${BleManager.DEVICE_SERVICE_UUID} on $deviceAddress.") + return false + } + val properties = characteristic.properties + val canWrite = (properties and BluetoothGattCharacteristic.PROPERTY_WRITE) > 0 + val canWriteNoResponse = (properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) > 0 + if (!canWrite && !canWriteNoResponse) { + Log.e(TAG, "sendSwitchCommand: Characteristic ${BleManager.SWITCH_UUID} does not support WRITE or WRITE_NO_RESPONSE. Properties: $properties") + return false + } + Log.d(TAG, "sendSwitchCommand: Characteristic ${BleManager.SWITCH_UUID} properties: $properties (Supports Write: ${canWrite || canWriteNoResponse})") + // --- End Verification --- + + return sendCommand(deviceAddress, BleManager.SWITCH_UUID, value) // Call the actual send logic + } + + // Helper in BleManager (add this if you don't have it) + + + + // Inside ProtocolRunner.kt + + // Inside ProtocolRunner.kt + + private suspend fun sendCommand(deviceAddress: String, charUUID: UUID, value: Int): Boolean { + val data: ByteArray? + val hexValue: String + if (value < 0 || value > 255) { + Log.w(TAG, "sendCommand: Value $value exceeds 1 byte for $charUUID. Sending LSB only (0x${(value and 0xFF).toString(16).padStart(2,'0').uppercase()}).") + hexValue = (value and 0xFF).toString(16).padStart(2, '0').uppercase() + } else { + hexValue = value.toString(16).padStart(2, '0').uppercase() + Log.d(TAG, "sendCommand: Formatting value $value as 1 Byte -> 0x$hexValue") + } + data = HexUtil.hexStringToBytes(hexValue) // Use HexUtil directly + + if (data == null) { + Log.e(TAG, "sendCommand: Failed to format data for value $value / hex $hexValue / UUID $charUUID.") + return false + } + + // --- End Data Formatting --- + + val gatt = bleManager.getGatt(deviceAddress) + // ...(GATT, Service, Characteristic, Property checks remain the same)... + if (gatt == null) { Log.e(TAG, "sendCommand: GATT null"); return false } + val service = gatt.getService(BleManager.DEVICE_SERVICE_UUID) + if (service == null) { Log.e(TAG, "sendCommand: Service null"); return false } + val characteristic = service.getCharacteristic(charUUID) + if (characteristic == null) { Log.e(TAG, "sendCommand: Characteristic $charUUID null"); return false } + val properties = characteristic.properties + val canWrite = (properties and BluetoothGattCharacteristic.PROPERTY_WRITE) > 0 + val canWriteNoResponse = (properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) > 0 + if (!canWrite && !canWriteNoResponse) { + Log.e(TAG, "sendCommand: Characteristic $charUUID no Write prop: $properties"); return false + } + Log.d(TAG, "sendCommand: Characteristic $charUUID properties: $properties (Writeable: ${canWrite || canWriteNoResponse})") + // --- End Verification --- + + val writeAttemptSuccess = bleManager.writeCharacteristic( + deviceAddress, + BleManager.DEVICE_SERVICE_UUID, + charUUID, + data + ) + Log.d(TAG, "sendCommand: bleManager.writeCharacteristic immediate return for $charUUID: $writeAttemptSuccess") + return writeAttemptSuccess + } + + + private fun calculateTotalTime(): Int { + var totalSeconds = 0 + for (i in SimpleProtocol.steps.indices) { + val step = SimpleProtocol.steps[i] + if (i in SimpleProtocol.cycleStartIndex..SimpleProtocol.cycleEndIndex) { + totalSeconds += step.durationSeconds * SimpleProtocol.CYCLES + } else { + if(step.durationSeconds > 0) { // Don't add time for hold steps + totalSeconds += step.durationSeconds + } + } + } + return totalSeconds + } + + private fun startStepTimer(durationSeconds: Int, scope: CoroutineScope) { + timerJob?.cancel() // Cancel previous timer if any + _remainingTimeSeconds.value = durationSeconds + timerJob = scope.launch(Dispatchers.Default) { // Use Default dispatcher for timer + Log.d(TAG, "Starting timer for $durationSeconds seconds") + for (t in durationSeconds downTo 1) { + if (!isActive) break // Exit if job is cancelled + _remainingTimeSeconds.value = t + _totalTimeSeconds.update { maxOf(0, it -1) } // Decrement total time + delay(1000) + } + if (isActive) { // Only set to 0 if not cancelled + _remainingTimeSeconds.value = 0 + Log.d(TAG, "Timer finished") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/data/model/BleDevice.kt b/app/src/main/java/in/sminnovations/pcr/data/model/BleDevice.kt new file mode 100644 index 0000000..b89552f --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/data/model/BleDevice.kt @@ -0,0 +1,14 @@ +package `in`.sminnovations.pcr.data.model + + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice + +// Simple data class to hold relevant scan result info +@SuppressLint("MissingPermission") // Permissions checked before use +data class BleDevice( + val name: String?, + val address: String, + val rssi: Int, + val bluetoothDevice: BluetoothDevice // Keep original device object if needed +) \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/data/model/ConnectionState.kt b/app/src/main/java/in/sminnovations/pcr/data/model/ConnectionState.kt new file mode 100644 index 0000000..1c41b9a --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/data/model/ConnectionState.kt @@ -0,0 +1,11 @@ +package `in`.sminnovations.pcr.data.model + + +enum class ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + FAILED, + SERVICE_DISCOVERY_FAILED, + READY // Indicates services discovered and ready for communication +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/data/model/SimpleProtocol.kt b/app/src/main/java/in/sminnovations/pcr/data/model/SimpleProtocol.kt new file mode 100644 index 0000000..09acf19 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/data/model/SimpleProtocol.kt @@ -0,0 +1,58 @@ +package `in`.sminnovations.pcr.data.model + + +// Keep Parcelable if you ever intend to pass whole steps around, otherwise remove it +// import android.os.Parcelable +// import kotlinx.parcelize.Parcelize + +// @Parcelize // Add if using Parcelable +data class ProtocolStep( + val name: String, + val temperature: Int, + val durationMinutes: Int = 0, // Add minutes field + val durationSeconds: Int +) // : Parcelable // Add if using Parcelable + + + +// ProtocolStep data class assumed to be updated with durationMinutes + +object SimpleProtocol { + const val CYCLES = 5 + + val steps = listOf( + // Use named arguments for clarity + ProtocolStep(name = "Initial Denaturation", temperature = 95, durationMinutes = 2, durationSeconds = 0), // 2 min 0 sec + // Start Cycles + ProtocolStep(name = "Denaturation", temperature = 95, durationMinutes = 0, durationSeconds = 30), + ProtocolStep(name = "Annealing", temperature = 55, durationMinutes = 0, durationSeconds = 30), + ProtocolStep(name = "Extension", temperature = 72, durationMinutes = 0, durationSeconds = 45), + // End Cycles + ProtocolStep(name = "Final Extension", temperature = 72, durationMinutes = 5, durationSeconds = 0), // 5 min 0 sec + ProtocolStep(name = "Hold", temperature = 4, durationMinutes = 0, durationSeconds = 0) // Hold step + ) + + // Define which steps belong to the repeating cycle (Indices are 0-based) + val cycleStartIndex = 1 // Index of "Denaturation" + val cycleEndIndex = 3 // Index of "Extension" + + // Helper to get total duration for display/timer (excluding Hold) + fun getStepTotalSeconds(step: ProtocolStep): Int { + if (step.name.contains("Hold", ignoreCase = true)) return 0 + return (step.durationMinutes * 60) + step.durationSeconds + } + + // Recalculate total time using the helper + fun calculateTotalTime(): Int { + var totalSeconds = 0 + // This simple calculation is incorrect for cycles. Needs proper loop simulation. + // Let's provide a basic estimate based on adding up individual durations * cycles + val cycleBlockDuration = (cycleStartIndex..cycleEndIndex).sumOf { getStepTotalSeconds(steps[it]) } + val nonCycleDuration = steps.indices + .filter { it < cycleStartIndex || it > cycleEndIndex } + .sumOf { getStepTotalSeconds(steps[it]) } + + totalSeconds = (cycleBlockDuration * CYCLES) + nonCycleDuration + return totalSeconds + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/data/utils/HexUtil.kt b/app/src/main/java/in/sminnovations/pcr/data/utils/HexUtil.kt new file mode 100644 index 0000000..9edec75 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/data/utils/HexUtil.kt @@ -0,0 +1,36 @@ +package`in`.sminnovations.pcr.data.utils + + +object HexUtil { + private val HEX_CHARS = "0123456789ABCDEF".toCharArray() + + fun bytesToHexString(bytes: ByteArray): String { + val hexChars = CharArray(bytes.size * 2) + for (j in bytes.indices) { + val v = bytes[j].toInt() and 0xFF + hexChars[j * 2] = HEX_CHARS[v ushr 4] + hexChars[j * 2 + 1] = HEX_CHARS[v and 0x0F] + } + return String(hexChars) + } + + fun hexStringToBytes(hexString: String?): ByteArray? { + if (hexString == null || hexString.length % 2 != 0) { + return null + } + val len = hexString.length + val data = ByteArray(len / 2) + var i = 0 + while (i < len) { + try { + data[i / 2] = ((Character.digit(hexString[i], 16) shl 4) + + Character.digit(hexString[i + 1], 16)).toByte() + } catch (e: Exception){ + // Handle potential NumberFormatException if string is not valid hex + return null + } + i += 2 + } + return data + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/di/AppModule.kt b/app/src/main/java/in/sminnovations/pcr/di/AppModule.kt new file mode 100644 index 0000000..9a32058 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/di/AppModule.kt @@ -0,0 +1,43 @@ +package `in`.sminnovations.pcr.di + +import `in`.sminnovations.pcr.ble.BleManager +import `in`.sminnovations.pcr.ble.ProtocolRunner +import android.content.Context +import android.speech.tts.TextToSpeech +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.util.* +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) // Lives as long as the application +object AppModule { + + @Provides + @Singleton // Ensure only one instance of BleManager + fun provideBleManager(@ApplicationContext context: Context): BleManager { + return BleManager(context) + } + + @Provides + @Singleton // Ensure only one instance of ProtocolRunner + fun provideProtocolRunner(bleManager: BleManager): ProtocolRunner { + return ProtocolRunner(bleManager) + } + + // Provide TextToSpeech instance if needed globally, otherwise create in ViewModel + @Provides + @Singleton + fun provideTextToSpeech(@ApplicationContext context: Context): TextToSpeech { + // Consider handling initialization result properly in a real app + return TextToSpeech(context) { status -> + if (status == TextToSpeech.SUCCESS) { + // Set language, pitch, etc. if needed + // tts.language = Locale.US // Example + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ui/components/LoadingIndicator.kt b/app/src/main/java/in/sminnovations/pcr/ui/components/LoadingIndicator.kt new file mode 100644 index 0000000..6d27e37 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ui/components/LoadingIndicator.kt @@ -0,0 +1,17 @@ +package `in`.sminnovations.pcr.ui.components + + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun LoadingIndicator(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ui/components/PermissionRationaleDialog.kt b/app/src/main/java/in/sminnovations/pcr/ui/components/PermissionRationaleDialog.kt new file mode 100644 index 0000000..2fb0a94 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ui/components/PermissionRationaleDialog.kt @@ -0,0 +1,30 @@ +package `in`.sminnovations.pcr.ui.components + + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun PermissionRationaleDialog( + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Permission Required") }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Grant") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ui/navigation/navigation.kt b/app/src/main/java/in/sminnovations/pcr/ui/navigation/navigation.kt new file mode 100644 index 0000000..54605ee --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ui/navigation/navigation.kt @@ -0,0 +1,44 @@ +package `in`.sminnovations.pcr.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.navigation.NavHostController +import `in`.sminnovations.pcr.ui.screen.DeviceControlScreen +import `in`.sminnovations.pcr.ui.screen.DeviceScanScreen +import `in`.sminnovations.pcr.ui.screen.RunProgramScreen + + +@Composable +fun AppNavigation( + navController: NavHostController = rememberNavController(), + startDestination: String = Screen.DeviceScan.route +) { + NavHost(navController = navController, startDestination = startDestination) { + composable(Screen.DeviceScan.route) { + DeviceScanScreen(navController = navController) + } + composable( + route = Screen.DeviceControl.route, + arguments = listOf(navArgument("deviceAddress") { type = NavType.StringType }) + ) { backStackEntry -> + val deviceAddress = backStackEntry.arguments?.getString("deviceAddress") + // Handle null address case appropriately, maybe navigate back or show error + deviceAddress?.let { + DeviceControlScreen(navController = navController, deviceAddress = it) + } + } + composable( + route = Screen.RunProgram.route, + arguments = listOf(navArgument("deviceAddress") { type = NavType.StringType }) + ) { backStackEntry -> + val deviceAddress = backStackEntry.arguments?.getString("deviceAddress") + deviceAddress?.let { + RunProgramScreen(navController = navController, deviceAddress = it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ui/navigation/screen.kt b/app/src/main/java/in/sminnovations/pcr/ui/navigation/screen.kt new file mode 100644 index 0000000..e15a87e --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ui/navigation/screen.kt @@ -0,0 +1,13 @@ +package `in`.sminnovations.pcr.ui.navigation + + + +sealed class Screen(val route: String) { + object DeviceScan : Screen("device_scan") + object DeviceControl : Screen("device_control/{deviceAddress}") { // Pass address as argument + fun createRoute(deviceAddress: String) = "device_control/$deviceAddress" + } + object RunProgram : Screen("run_program/{deviceAddress}") { // Pass address as argument + fun createRoute(deviceAddress: String) = "run_program/$deviceAddress" + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ui/screen/DeviceControlScreen.kt b/app/src/main/java/in/sminnovations/pcr/ui/screen/DeviceControlScreen.kt new file mode 100644 index 0000000..6d844e5 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ui/screen/DeviceControlScreen.kt @@ -0,0 +1,97 @@ +package `in`.sminnovations.pcr.ui.screen + + + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import `in`.sminnovations.pcr.data.model.ConnectionState +import `in`.sminnovations.pcr.ui.components.LoadingIndicator +import `in`.sminnovations.pcr.ui.navigation.Screen +import `in`.sminnovations.pcr.viewmodel.DeviceControlViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceControlScreen( + navController: NavController, + deviceAddress: String, + viewModel: DeviceControlViewModel = hiltViewModel() +) { + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Device Control") }, + navigationIcon = { + // IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text("Device: $deviceAddress", style = MaterialTheme.typography.headlineSmall) + Spacer(modifier = Modifier.height(16.dp)) + + when (connectionState) { + ConnectionState.CONNECTING -> { + LoadingIndicator() + Text("Connecting...") + } + ConnectionState.CONNECTED, ConnectionState.READY -> { + Text("Status: ${connectionState.name}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + // Navigate to Run Program Screen + navController.navigate(Screen.RunProgram.createRoute(deviceAddress)) + }, + enabled = connectionState == ConnectionState.READY // Only enable when fully ready + ) { + Text("Run Protocol") + } + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.disconnect() }) { + Text("Disconnect") + } + } + ConnectionState.DISCONNECTED -> { + Text("Status: Disconnected", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.attemptReconnect() }) { + Text("Reconnect") + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { navController.popBackStack(Screen.DeviceScan.route, inclusive = false) }) { + Text("Scan Again") + } + } + ConnectionState.FAILED, ConnectionState.SERVICE_DISCOVERY_FAILED -> { + Text("Status: Connection Failed", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.attemptReconnect() }) { + Text("Retry Connection") + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { navController.popBackStack(Screen.DeviceScan.route, inclusive = false) }) { + Text("Scan Again") + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ui/screen/DeviceScanScreen.kt b/app/src/main/java/in/sminnovations/pcr/ui/screen/DeviceScanScreen.kt new file mode 100644 index 0000000..eb943c4 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ui/screen/DeviceScanScreen.kt @@ -0,0 +1,213 @@ +package `in`.sminnovations.pcr.ui.screen + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +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.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.google.accompanist.permissions.* +import `in`.sminnovations.pcr.data.model.BleDevice +import `in`.sminnovations.pcr.ui.components.LoadingIndicator +import `in`.sminnovations.pcr.ui.components.PermissionRationaleDialog +import `in`.sminnovations.pcr.ui.navigation.Screen +import `in`.sminnovations.pcr.viewmodel.DeviceScanViewModel + +@SuppressLint("MissingPermission") +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) +@Composable +fun DeviceScanScreen( + navController: NavController, + viewModel: DeviceScanViewModel = hiltViewModel() +) { + val scanResults by viewModel.scanResults.collectAsStateWithLifecycle() + val isScanning by viewModel.isScanning.collectAsStateWithLifecycle() + val errorState by viewModel.errorState.collectAsStateWithLifecycle() + val isBluetoothEnabled by viewModel.isBluetoothEnabled.collectAsStateWithLifecycle() + val context = LocalContext.current + + // --- Permission Handling --- + val permissions = viewModel.getRequiredPermissions() + val permissionState = rememberMultiplePermissionsState(permissions = permissions) + var showRationaleDialog by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = permissionState.allPermissionsGranted) { + if (!permissionState.allPermissionsGranted && !permissionState.shouldShowRationale) { + // First time or permanently denied + } + } + + // --- UI --- + Scaffold( + topBar = { + TopAppBar(title = { Text("Scan Devices") }) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + text = { Text(if (isScanning) "Scanning..." else "Scan") }, + icon = { /* Add Icon if desired */ }, + onClick = { + if (permissionState.allPermissionsGranted) { + if (!isBluetoothEnabled) { + // Prompt user to enable Bluetooth (using system intent) + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + // Consider using ActivityResultLauncher for better handling + context.startActivity(enableBtIntent) + } else if (!isScanning) { + viewModel.startScan() + } else { + viewModel.stopScan() + } + } else { + // Handle different permission states + if (permissionState.shouldShowRationale) { + showRationaleDialog = true // Show explanation + } else { + permissionState.launchMultiplePermissionRequest() // Request again or first time + } + } + }, + expanded = !isScanning // Example: shrink while scanning + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (!permissionState.allPermissionsGranted) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val textToShow = if (permissionState.shouldShowRationale) { + "Bluetooth and Location permissions are needed to scan for devices. Please grant the permissions." + } else { + "Device scanning requires Bluetooth and Location permissions. Please grant them in settings." + } + Text(textToShow, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + if (permissionState.shouldShowRationale) { + permissionState.launchMultiplePermissionRequest() + } else { + // Guide user to settings (implement Intent logic) + // val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + // val uri = Uri.fromParts("package", context.packageName, null) + // intent.data = uri + // context.startActivity(intent) + permissionState.launchMultiplePermissionRequest() // Try requesting again first + } + }) { + Text(if (permissionState.shouldShowRationale) "Grant Permissions" else "Request Permissions") + } + } + } else if (!isBluetoothEnabled) { + Text("Please enable Bluetooth to scan for devices.") + Button(onClick = { + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + context.startActivity(enableBtIntent) + }) { + Text("Enable Bluetooth") + } + } else { + // Display Scan Results or Loading/Error + if (isScanning) { + LoadingIndicator(modifier = Modifier.padding(top = 20.dp)) + } + + errorState?.let { error -> + Text( + text = "Error: $error", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 8.dp) + ) + Button(onClick = { viewModel.clearError() }) { Text("Dismiss") } + } + + if (scanResults.isEmpty() && !isScanning && errorState == null) { + Text("No devices found. Tap 'Scan' to start.", modifier = Modifier.padding(top = 20.dp)) + } else { + DeviceList(devices = scanResults) { deviceAddress -> + viewModel.connectDevice(deviceAddress) + // Navigate to control screen after initiating connection + navController.navigate(Screen.DeviceControl.createRoute(deviceAddress)) { + // Optional: configure navigation behavior (e.g., popUpTo) + } + } + } + } + + // Optional Rationale Dialog + if (showRationaleDialog) { + PermissionRationaleDialog( + message = "Scanning for Bluetooth devices requires Location and Bluetooth permissions. Please grant access to continue.", + onConfirm = { + showRationaleDialog = false + permissionState.launchMultiplePermissionRequest() + }, + onDismiss = { showRationaleDialog = false } + ) + } + } + } +} + +@Composable +fun DeviceList(devices: List, onDeviceClick: (String) -> Unit) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items = devices, key = { it.address }) { device -> + DeviceListItem(device = device, onClick = { onDeviceClick(device.address) }) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceListItem(device: BleDevice, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = device.name ?: "Unknown Device", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = device.address, + style = MaterialTheme.typography.bodySmall + ) + } + Text( + text = "${device.rssi} dBm", + style = MaterialTheme.typography.bodyMedium + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ui/screen/RunProgramScreen.kt b/app/src/main/java/in/sminnovations/pcr/ui/screen/RunProgramScreen.kt new file mode 100644 index 0000000..10f3bd8 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ui/screen/RunProgramScreen.kt @@ -0,0 +1,181 @@ +package `in`.sminnovations.pcr.ui.screen + + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import `in`.sminnovations.pcr.data.model.ConnectionState +import `in`.sminnovations.pcr.data.model.ProtocolStep +import `in`.sminnovations.pcr.ui.navigation.Screen +import `in`.sminnovations.pcr.viewmodel.RunProgramViewModel +import java.util.Locale +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RunProgramScreen( + navController: NavController, + deviceAddress: String, // Passed via navigation + viewModel: RunProgramViewModel = hiltViewModel() +) { + val isRunning by viewModel.isRunning.collectAsStateWithLifecycle() + val currentStepIndex by viewModel.currentStepIndex.collectAsStateWithLifecycle() + val remainingTime by viewModel.remainingTimeSeconds.collectAsStateWithLifecycle() + val totalTime by viewModel.totalTimeSeconds.collectAsStateWithLifecycle() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val steps = viewModel.protocolSteps + + val currentStep = if (currentStepIndex >= 0 && currentStepIndex < steps.size) steps[currentStepIndex] else null + + LaunchedEffect(connectionState) { + if (connectionState != ConnectionState.READY && connectionState != ConnectionState.CONNECTED) { + // Handle disconnection during run - maybe pop back or show error dialog + // ViewModel already stops the protocol runner + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Run Protocol") }, + navigationIcon = { + // Optionally allow navigating back if protocol isn't running + // IconButton(onClick = { if(!isRunning) navController.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Device: $deviceAddress", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text("Connection: ${connectionState.name}", style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(16.dp)) + + // Progress Overview + if(isRunning && currentStep != null) { + LinearProgressIndicator( + progress = if (totalTime > 0) 1f - (remainingTime.toFloat() / currentStep.durationSeconds.toFloat()) else 0f , + modifier = Modifier.fillMaxWidth().height(8.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Step ${currentStepIndex + 1} / ${steps.size}: ${currentStep.name}", + style = MaterialTheme.typography.titleLarge + ) + Text( + "Temp: ${currentStep.temperature}°C", + style = MaterialTheme.typography.titleMedium + ) + Text( + "Time Remaining: ${formatTime(remainingTime)}", + style = MaterialTheme.typography.titleMedium + ) + Text( + "Total Time Approx: ${formatTime(totalTime)}", + style = MaterialTheme.typography.bodySmall + ) + + } else if (currentStepIndex >= steps.size) { + Text("Protocol Finished!", style = MaterialTheme.typography.headlineMedium) + } else { + Text("Ready to start.", style = MaterialTheme.typography.headlineMedium) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Protocol Steps List + LazyColumn(modifier = Modifier.weight(1f)) { + itemsIndexed(items = steps, key = { index, _ -> index }) { index, step -> + ProtocolStepItem( + step = step, + isCurrent = index == currentStepIndex && isRunning, + isCycleStep = index in viewModel.cycleStartIndex..viewModel.cycleEndIndex + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Control Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Button( + onClick = { viewModel.startProtocol() }, + enabled = !isRunning && connectionState == ConnectionState.READY + ) { + Text("Start") + } + Button( + onClick = { viewModel.stopProtocol() }, + enabled = isRunning + ) { + Text("Stop") + } + } + // Add Disconnected message/action + if(connectionState != ConnectionState.CONNECTED && connectionState != ConnectionState.READY) { + Spacer(Modifier.height(10.dp)) + Text("Device disconnected.", color = MaterialTheme.colorScheme.error) + Button(onClick = { navController.popBackStack(Screen.DeviceScan.route, false)}){ Text("Go Back to Scan")} + } + } + } +} + + +@Composable +fun ProtocolStepItem(step: ProtocolStep, isCurrent: Boolean, isCycleStep: Boolean) { + val backgroundColor = if (isCurrent) MaterialTheme.colorScheme.primaryContainer else Color.Transparent + val cycleIndicator = if (isCycleStep) " (Cycle)" else "" + + Row( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "${step.name}$cycleIndicator", + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.weight(1f) // Allow text to wrap if needed + ) + Text( + text = "${step.temperature}°C", + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.padding(horizontal = 8.dp) + ) + Text( + text = if(step.durationSeconds > 0) formatTime(step.durationSeconds) else "Hold", + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal + ) + } + Divider() +} + +fun formatTime(totalSeconds: Int): String { + if (totalSeconds <= 0) return "00:00" + val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds.toLong()) + val seconds = totalSeconds - TimeUnit.MINUTES.toSeconds(minutes) + return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ui/theme/Color.kt b/app/src/main/java/in/sminnovations/pcr/ui/theme/Color.kt new file mode 100644 index 0000000..a2431f4 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package `in`.sminnovations.pcr.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ui/theme/Theme.kt b/app/src/main/java/in/sminnovations/pcr/ui/theme/Theme.kt new file mode 100644 index 0000000..ce9fa28 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package `in`.sminnovations.pcr.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 = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* 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 PCRAppTheme( + 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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/ui/theme/Type.kt b/app/src/main/java/in/sminnovations/pcr/ui/theme/Type.kt new file mode 100644 index 0000000..93b3de6 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package `in`.sminnovations.pcr.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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/viewmodel/DeviceControlViewModel.kt b/app/src/main/java/in/sminnovations/pcr/viewmodel/DeviceControlViewModel.kt new file mode 100644 index 0000000..6809e4f --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/viewmodel/DeviceControlViewModel.kt @@ -0,0 +1,34 @@ +package `in`.sminnovations.pcr.viewmodel + +import `in`.sminnovations.pcr.ble.BleManager +import `in`.sminnovations.pcr.data.model.ConnectionState +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@HiltViewModel +class DeviceControlViewModel @Inject constructor( + private val bleManager: BleManager, + savedStateHandle: SavedStateHandle // Used to get navigation arguments +) : ViewModel() { + + val deviceAddress: String = savedStateHandle.get("deviceAddress") ?: "Unknown Address" + + val connectionState: StateFlow = bleManager.connectionStates + .map { it[deviceAddress] ?: ConnectionState.DISCONNECTED } // Get state for this specific device + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ConnectionState.DISCONNECTED) + + fun disconnect() { + bleManager.disconnectFromDevice(deviceAddress) + } + + fun attemptReconnect() { + // Re-initiate connection if disconnected or failed + if (connectionState.value == ConnectionState.DISCONNECTED || connectionState.value == ConnectionState.FAILED) { + bleManager.connectToDevice(deviceAddress) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/viewmodel/DeviceScanViewModel.kt b/app/src/main/java/in/sminnovations/pcr/viewmodel/DeviceScanViewModel.kt new file mode 100644 index 0000000..042455b --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/viewmodel/DeviceScanViewModel.kt @@ -0,0 +1,88 @@ +package `in`.sminnovations.pcr.viewmodel + + +import android.app.Application +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import `in`.sminnovations.pcr.ble.BleManager +import `in`.sminnovations.pcr.ble.BleState +import `in`.sminnovations.pcr.data.model.BleDevice +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DeviceScanViewModel @Inject constructor( + private val bleManager: BleManager, + application: Application // Needed for BluetoothManager check +) : AndroidViewModel(application) { + + val scanResults: StateFlow> = bleManager.scanResults + val isScanning = MutableStateFlow(false) // Track scanning state locally + + private val _errorState = MutableStateFlow(null) + val errorState: StateFlow = _errorState.asStateFlow() + + // Check if Bluetooth is enabled + val isBluetoothEnabled: StateFlow = flow { + val bluetoothManager = getApplication().getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val adapter = bluetoothManager.adapter + while (true) { + emit(adapter?.isEnabled == true) + kotlinx.coroutines.delay(1000) // Check periodically + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + + init { + // Observe BLE Manager states for errors or scan finish + viewModelScope.launch { + bleManager.bleState.collect { state -> + when (state) { + is BleState.ScanFinished -> isScanning.value = false + is BleState.Error -> { + _errorState.value = state.message + isScanning.value = false // Stop scanning on error + } + else -> { /* Handle other states if needed */ } + } + } + } + } + + fun getRequiredPermissions(): List = bleManager.requiredPermissions + + fun startScan() { + if (!isScanning.value) { + isScanning.value = true + _errorState.value = null // Clear previous errors + bleManager.startBleScan() + } + } + + fun stopScan() { + if (isScanning.value) { + bleManager.stopBleScan() + // isScanning state will be updated via BleState.ScanFinished + } + } + + fun connectDevice(deviceAddress: String) { + stopScan() // Stop scanning before connecting + bleManager.connectToDevice(deviceAddress) + // Connection status will be observed in the next screen's ViewModel + } + + fun clearError() { + _errorState.value = null + } + + override fun onCleared() { + super.onCleared() + bleManager.stopBleScan() // Ensure scan is stopped when ViewModel is cleared + } +} \ No newline at end of file diff --git a/app/src/main/java/in/sminnovations/pcr/viewmodel/RunProgramViewModel.kt b/app/src/main/java/in/sminnovations/pcr/viewmodel/RunProgramViewModel.kt new file mode 100644 index 0000000..e5659a7 --- /dev/null +++ b/app/src/main/java/in/sminnovations/pcr/viewmodel/RunProgramViewModel.kt @@ -0,0 +1,132 @@ +package `in`.sminnovations.pcr.viewmodel + +import android.speech.tts.TextToSpeech +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import `in`.sminnovations.pcr.ble.BleManager +import `in`.sminnovations.pcr.ble.BleState +import `in`.sminnovations.pcr.ble.ProtocolRunner +import `in`.sminnovations.pcr.data.model.ConnectionState +import `in`.sminnovations.pcr.data.model.SimpleProtocol +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.util.* +import javax.inject.Inject + +private const val TAG = "RunProgramVM" + +@HiltViewModel +class RunProgramViewModel @Inject constructor( + private val bleManager: BleManager, + private val protocolRunner: ProtocolRunner, + private val textToSpeech: TextToSpeech, // Inject TTS + savedStateHandle: SavedStateHandle +) : ViewModel() { + + val deviceAddress: String = savedStateHandle.get("deviceAddress") ?: "Unknown Address" + + val connectionState: StateFlow = bleManager.connectionStates + .map { it[deviceAddress] ?: ConnectionState.DISCONNECTED } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ConnectionState.DISCONNECTED) + + val isRunning: StateFlow = protocolRunner.isRunning + val currentStepIndex: StateFlow = protocolRunner.currentStepIndex + val remainingTimeSeconds: StateFlow = protocolRunner.remainingTimeSeconds + val totalTimeSeconds: StateFlow = protocolRunner.totalTimeSeconds + + private val _lastSpokenMessage = MutableStateFlow(null) // To avoid repeating TTS + + val protocolSteps = SimpleProtocol.steps + val totalCycles = SimpleProtocol.CYCLES + val cycleStartIndex = SimpleProtocol.cycleStartIndex + val cycleEndIndex = SimpleProtocol.cycleEndIndex + + + init { + // Listen for relevant BLE state changes (like data received or errors) + viewModelScope.launch { + bleManager.bleState.collect { state -> + when(state) { + is BleState.DataReceived -> { + if(state.deviceAddress == deviceAddress) { + // Handle received data if needed (e.g., status updates from device) + Log.d(TAG,"Received from ${state.characteristicUUID}: ${state.data.contentToString()}") + } + } + is BleState.Error -> { + Log.e(TAG, "BLE Error received: ${state.message}") + // Optionally stop protocol on critical errors + if(isRunning.value) { + protocolRunner.stopProtocol() + } + } + is BleState.ConnectionUpdate -> { + // Stop protocol if device disconnects unexpectedly + if(state.deviceAddress == deviceAddress && state.state != ConnectionState.CONNECTED && state.state != ConnectionState.READY && isRunning.value) { + Log.w(TAG, "Device disconnected during protocol run. Stopping.") + protocolRunner.stopProtocol() + speak("Device disconnected. Protocol stopped.") + } + } + else -> { /* No op */ } + } + } + } + + // Observe running state changes for TTS announcements + viewModelScope.launch { + isRunning.collect { running -> + if(!running && currentStepIndex.value >= protocolSteps.size -1) { // Check if finished, not just stopped early + speak("Protocol finished.") + } else if (!running && currentStepIndex.value > -1) { // Stopped mid-run + speak("Protocol stopped.") + } + } + } + + // Observe step changes for TTS + viewModelScope.launch { + currentStepIndex.collect { index -> + if (index >= 0 && index < protocolSteps.size) { + val step = protocolSteps[index] + speak("Starting step ${index + 1}: ${step.name}") + } + } + } + } + + fun startProtocol() { + if (connectionState.value == ConnectionState.READY && !isRunning.value) { + speak("Starting protocol.") + protocolRunner.startProtocol(deviceAddress, viewModelScope) + } else { + Log.w(TAG, "Cannot start protocol. State: ${connectionState.value}, Running: ${isRunning.value}") + speak("Cannot start protocol. Please ensure device is connected and ready.") + } + } + + fun stopProtocol() { + if (isRunning.value) { + speak("Stopping protocol.") + protocolRunner.stopProtocol() + } + } + + private fun speak(text: String) { + // Simple check to avoid repeating the exact same message immediately + if (_lastSpokenMessage.value != text) { + textToSpeech.speak(text, TextToSpeech.QUEUE_ADD, null, UUID.randomUUID().toString()) + _lastSpokenMessage.value = text + } + } + + override fun onCleared() { + super.onCleared() + stopProtocol() // Ensure protocol is stopped + // Don't shut down TTS here if it's a Singleton provided by Hilt + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_close_24.xml b/app/src/main/res/drawable/baseline_close_24.xml new file mode 100644 index 0000000..f8ca0c6 --- /dev/null +++ b/app/src/main/res/drawable/baseline_close_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_menu_24.xml b/app/src/main/res/drawable/baseline_menu_24.xml new file mode 100644 index 0000000..90a85a6 --- /dev/null +++ b/app/src/main/res/drawable/baseline_menu_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..143da58 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + PCR app + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1bd0a96 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +