initial commit

This commit is contained in:
Sathwik
2025-08-06 17:41:36 +05:30
commit f79ebfdecc
68 changed files with 2931 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -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

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
PCR app

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

10
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

18
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

6
.idea/kotlinc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.0" />
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

10
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

92
app/build.gradle.kts Normal file
View File

@@ -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")
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<application
android:name=".PCRApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.PCRApp"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.PCRApp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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()
}
}
}
}

View File

@@ -0,0 +1,9 @@
package `in`.sminnovations.pcr
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class PCRApplication : Application() {
}

View File

@@ -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<BleState>(
replay = 0, // No replay for events
extraBufferCapacity = 10, // Buffer events if collector is slow
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val bleState: SharedFlow<BleState> = _bleState.asSharedFlow()
private val _scanResults = MutableStateFlow<List<BleDevice>>(emptyList())
val scanResults: StateFlow<List<BleDevice>> = _scanResults.asStateFlow()
// Store connection state per device address
private val _connectionStates = MutableStateFlow<Map<String, ConnectionState>>(emptyMap())
val connectionStates: StateFlow<Map<String, ConnectionState>> = _connectionStates.asStateFlow()
private val connectedGattMap = mutableMapOf<String, BluetoothGatt>()
private val gattCallbackMap = mutableMapOf<String, BluetoothGattCallback>()
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<String>
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<ScanResult>?) {
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
}
}
}

View File

@@ -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()
}

View File

@@ -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<Boolean> = _isRunning.asStateFlow()
private val _currentStepIndex = MutableStateFlow(-1) // -1 means not started
val currentStepIndex: StateFlow<Int> = _currentStepIndex.asStateFlow()
private val _remainingTimeSeconds = MutableStateFlow(0)
val remainingTimeSeconds: StateFlow<Int> = _remainingTimeSeconds.asStateFlow()
private val _totalTimeSeconds = MutableStateFlow(0)
val totalTimeSeconds: StateFlow<Int> = _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<UUID?, UUID?, UUID?> {
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")
}
}
}
}

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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))
}
}

View File

@@ -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")
}
}
)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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")
}
}
}
}
}
}

View File

@@ -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<BleDevice>, 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
)
}
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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
)
*/
)

View File

@@ -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<String>("deviceAddress") ?: "Unknown Address"
val connectionState: StateFlow<ConnectionState> = 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)
}
}
}

View File

@@ -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<List<BleDevice>> = bleManager.scanResults
val isScanning = MutableStateFlow(false) // Track scanning state locally
private val _errorState = MutableStateFlow<String?>(null)
val errorState: StateFlow<String?> = _errorState.asStateFlow()
// Check if Bluetooth is enabled
val isBluetoothEnabled: StateFlow<Boolean> = flow {
val bluetoothManager = getApplication<Application>().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<String> = 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
}
}

View File

@@ -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<String>("deviceAddress") ?: "Unknown Address"
val connectionState: StateFlow<ConnectionState> = bleManager.connectionStates
.map { it[deviceAddress] ?: ConnectionState.DISCONNECTED }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ConnectionState.DISCONNECTED)
val isRunning: StateFlow<Boolean> = protocolRunner.isRunning
val currentStepIndex: StateFlow<Int> = protocolRunner.currentStepIndex
val remainingTimeSeconds: StateFlow<Int> = protocolRunner.remainingTimeSeconds
val totalTimeSeconds: StateFlow<Int> = protocolRunner.totalTimeSeconds
private val _lastSpokenMessage = MutableStateFlow<String?>(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
}
}

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z"/>
</vector>

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

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

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">PCR app</string>
</resources>

View File

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

View File

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

View File

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

View File

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

6
build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
id("com.google.dagger.hilt.android") version "2.51.1" apply false
}

23
gradle.properties Normal file
View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

36
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,36 @@
[versions]
agp = "8.5.0"
kotlin = "1.9.0"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.6"
activityCompose = "1.9.2"
composeBom = "2023.08.00"
datastoreCoreAndroid = "1.1.1"
datastorePreferencesCoreJvm = "1.1.1"
material3Android = "1.3.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" }
androidx-datastore-core-android = { group = "androidx.datastore", name = "datastore-core-android", version.ref = "datastoreCoreAndroid" }
androidx-datastore-preferences-core-jvm = { group = "androidx.datastore", name = "datastore-preferences-core-jvm", version.ref = "datastorePreferencesCoreJvm" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#Mon Feb 10 12:27:47 IST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Normal file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

24
settings.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "PCR app"
include(":app")