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