initial commit
15
.gitignore
vendored
Normal 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
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
1
.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
PCR app
|
||||
6
.idea/compiler.xml
generated
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
/build
|
||||
92
app/build.gradle.kts
Normal 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
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
39
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
24
app/src/main/java/in/sminnovations/pcr/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
app/src/main/java/in/sminnovations/pcr/PCRApplication.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package `in`.sminnovations.pcr
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class PCRApplication : Application() {
|
||||
|
||||
}
|
||||
403
app/src/main/java/in/sminnovations/pcr/ble/BleManager.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/in/sminnovations/pcr/ble/BleState.kt
Normal 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()
|
||||
}
|
||||
432
app/src/main/java/in/sminnovations/pcr/ble/ProtocolRunner.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/in/sminnovations/pcr/data/utils/HexUtil.kt
Normal 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
|
||||
}
|
||||
}
|
||||
43
app/src/main/java/in/sminnovations/pcr/di/AppModule.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
11
app/src/main/java/in/sminnovations/pcr/ui/theme/Color.kt
Normal 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)
|
||||
57
app/src/main/java/in/sminnovations/pcr/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
34
app/src/main/java/in/sminnovations/pcr/ui/theme/Type.kt
Normal 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
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/drawable/baseline_close_24.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/baseline_menu_24.xml
Normal 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>
|
||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal 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>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
app/src/main/res/values/colors.xml
Normal 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>
|
||||
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">PCR app</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.PCRApp" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
17
app/src/test/java/in/sminnovations/pcr/ExampleUnitTest.kt
Normal 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
@@ -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
@@ -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
@@ -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
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||